@clubedge/create-clubedge-app 0.44.0 → 0.45.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 (122) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +12 -10
  3. package/dist/core/merge.js +26 -12
  4. package/dist/core/package-json-merge.js +106 -0
  5. package/package.json +3 -3
  6. package/template/.github/workflows/ci.yml +1 -1
  7. package/template/.railway/railway.ts +9 -0
  8. package/template/README.md +24 -15
  9. package/template/SETUP.md +49 -23
  10. package/template/apps/api/.env.example +22 -5
  11. package/template/apps/api/package.json +1 -1
  12. package/template/apps/api/src/app.test.ts +24 -1
  13. package/template/apps/api/src/app.ts +45 -3
  14. package/template/apps/api/src/config/site.json +3 -1
  15. package/template/apps/api/src/routes/app-version.test.ts +68 -7
  16. package/template/apps/api/src/routes/app-version.ts +10 -3
  17. package/template/apps/api/src/server/app-version.test.ts +109 -7
  18. package/template/apps/api/src/server/app-version.ts +94 -44
  19. package/template/apps/api-fastapi/.env.example +26 -9
  20. package/template/apps/api-fastapi/package.json +1 -1
  21. package/template/apps/api-fastapi/pyproject.toml +1 -1
  22. package/template/apps/api-fastapi/src/clubedge_api/app.py +41 -3
  23. package/template/apps/api-fastapi/src/clubedge_api/app_version.py +160 -28
  24. package/template/apps/api-fastapi/src/clubedge_api/contract.py +4 -0
  25. package/template/apps/api-fastapi/src/clubedge_api/routes/app_version.py +17 -8
  26. package/template/apps/api-fastapi/src/config/site.json +3 -1
  27. package/template/apps/api-fastapi/tests/test_app_version.py +247 -20
  28. package/template/apps/api-fastapi/uv.lock +1 -1
  29. package/template/apps/capacitor/assets/splash-dark.svg +1 -0
  30. package/template/apps/capacitor/assets/splash-icon.png +0 -0
  31. package/template/apps/capacitor/assets/splash.svg +1 -0
  32. package/template/apps/capacitor/package.json +1 -1
  33. package/template/apps/capacitor/scripts/icons.mjs +113 -14
  34. package/template/apps/expo/app.config.ts +4 -1
  35. package/template/apps/expo/clubedge.stack.json +6 -1
  36. package/template/apps/expo/package.json +7 -2
  37. package/template/apps/expo/src/app/(app)/_layout.tsx +4 -23
  38. package/template/apps/expo/src/app/(app)/index.tsx +164 -44
  39. package/template/apps/expo/src/app/(app)/settings/_layout.tsx +12 -0
  40. package/template/apps/expo/src/app/(app)/settings/delete-account.tsx +76 -0
  41. package/template/apps/expo/src/app/(app)/settings/edit-profile.tsx +44 -0
  42. package/template/apps/expo/src/app/(app)/settings/index.tsx +90 -0
  43. package/template/apps/expo/src/app/(app)/settings/password.tsx +49 -0
  44. package/template/apps/expo/src/app/_layout.tsx +12 -1
  45. package/template/apps/expo/src/app/forgot-password.tsx +47 -39
  46. package/template/apps/expo/src/app/reset-password.tsx +6 -8
  47. package/template/apps/expo/src/app/sign-in.tsx +29 -12
  48. package/template/apps/expo/src/app/sign-up.tsx +42 -14
  49. package/template/apps/expo/src/app/welcome.tsx +99 -0
  50. package/template/apps/expo/src/components/sign-out-sheet.tsx +138 -0
  51. package/template/apps/expo/src/components/tab-bar.tsx +143 -0
  52. package/template/apps/expo/src/components/ui.tsx +545 -81
  53. package/template/apps/expo/src/components/update-gate.tsx +327 -37
  54. package/template/apps/expo/src/config/site.json +3 -1
  55. package/template/apps/expo/src/lib/account.ts +62 -0
  56. package/template/apps/expo/src/lib/app-update.ts +36 -0
  57. package/template/apps/expo/src/lib/auth-client.ts +10 -2
  58. package/template/apps/expo/src/lib/theme.ts +152 -15
  59. package/template/apps/expo/src/types/images.d.ts +13 -0
  60. package/template/apps/expo/src/variants/app-layout.auth.tsx +17 -0
  61. package/template/apps/expo/src/variants/auth.better-auth.ts +12 -2
  62. package/template/apps/expo/src/variants/auth.supabase.ts +8 -5
  63. package/template/apps/expo/src/variants/layout.auth.tsx +24 -13
  64. package/template/apps/start/package.json +1 -1
  65. package/template/apps/start/src/config/site.json +3 -1
  66. package/template/apps/vite/.env.example +4 -4
  67. package/template/apps/vite/package.json +1 -1
  68. package/template/apps/vite/server.mjs +2 -1
  69. package/template/apps/vite/src/config/site.json +3 -1
  70. package/template/apps/vite/src/main.tsx +4 -3
  71. package/template/apps/web/package.json +1 -1
  72. package/template/apps/web/src/config/site.json +3 -1
  73. package/template/clubedge.template.json +1 -1
  74. package/template/package.json +3 -2
  75. package/template/packages/auth/package.json +1 -1
  76. package/template/packages/auth-appwrite/package.json +1 -1
  77. package/template/packages/auth-better-auth/package.json +1 -1
  78. package/template/packages/auth-firebase/package.json +1 -1
  79. package/template/packages/auth-supabase/package.json +1 -1
  80. package/template/packages/cache/package.json +1 -1
  81. package/template/packages/cache-redis/package.json +1 -1
  82. package/template/packages/contract/openapi.yaml +85 -3
  83. package/template/packages/contract/package.json +1 -1
  84. package/template/packages/contract/src/app-update.test.ts +321 -0
  85. package/template/packages/contract/src/app-update.ts +237 -0
  86. package/template/packages/contract/src/app-version.test.ts +76 -0
  87. package/template/packages/contract/src/app-version.ts +71 -0
  88. package/template/packages/contract/src/conformance.ts +42 -1
  89. package/template/packages/contract/src/index.ts +2 -0
  90. package/template/packages/contract/src/schema.ts +44 -4
  91. package/template/packages/core/package.json +1 -1
  92. package/template/packages/db/package.json +1 -1
  93. package/template/packages/email/package.json +1 -1
  94. package/template/packages/email-resend/package.json +1 -1
  95. package/template/packages/email-smtp/package.json +1 -1
  96. package/template/packages/jobs/package.json +1 -1
  97. package/template/packages/jobs-bullmq/package.json +1 -1
  98. package/template/packages/jobs-inngest/package.json +1 -1
  99. package/template/packages/jobs-pg-boss/package.json +1 -1
  100. package/template/packages/local-db/package.json +1 -1
  101. package/template/packages/mobile/package.json +1 -1
  102. package/template/packages/mobile/src/demo/api.ts +7 -1
  103. package/template/packages/mobile/src/demo/demo.test.ts +2 -0
  104. package/template/packages/mobile/src/update-notice.test.ts +26 -2
  105. package/template/packages/mobile/src/update-notice.tsx +55 -41
  106. package/template/packages/observability/package.json +1 -1
  107. package/template/packages/observability-otel/package.json +1 -1
  108. package/template/packages/observability-sentry/package.json +1 -1
  109. package/template/packages/payments/package.json +1 -1
  110. package/template/packages/payments-stripe/package.json +1 -1
  111. package/template/packages/storage/package.json +1 -1
  112. package/template/packages/storage-s3/package.json +1 -1
  113. package/template/packages/storage-supabase/package.json +1 -1
  114. package/template/packages/ui/package.json +1 -1
  115. package/template/pnpm-lock.yaml +146 -5
  116. package/template/supabase/config.toml +10 -0
  117. package/template.lock.json +21 -7
  118. package/template/apps/expo/src/app/(app)/settings.tsx +0 -168
  119. package/template/apps/expo/src/lib/update.test.ts +0 -34
  120. package/template/apps/expo/src/lib/update.ts +0 -40
  121. package/template/packages/mobile/src/version.test.ts +0 -65
  122. package/template/packages/mobile/src/version.ts +0 -37
@@ -20,16 +20,33 @@ DATABASE_URL= # clubedge:only infra=neon
20
20
  DATABASE_URL_DIRECT=
21
21
 
22
22
  # Optional: what /api/app-version tells apps installed on phones, as MAJOR.MINOR.PATCH versions
23
- # such as 1.4.0. Apps below the minimum must update before they continue; apps below the latest
24
- # may show an update notice. The update URL is an https:// store listing or download page.
23
+ # such as 1.4.0. Apps below the minimum must update before they continue, and the API refuses
24
+ # their requests; apps below the latest may show an update notice. The update URL is an https://
25
+ # store listing or download page.
25
26
  APP_MINIMUM_VERSION=
26
27
  APP_LATEST_VERSION=
27
28
  APP_UPDATE_URL=
28
-
29
+ # Optional: a minimum announced for a date and time with its time zone, such as
30
+ # 2026-11-01T00:00:00Z. Until then, older apps keep working and warn their users; from then, the
31
+ # API requires it, with no redeploy.
32
+ APP_NEXT_MINIMUM_VERSION=
33
+ APP_NEXT_MINIMUM_VERSION_FROM=
34
+ # Each APP_* variable can be set for one platform by adding _IOS or _ANDROID to its name, such as
35
+ # APP_UPDATE_URL_IOS for the App Store listing and APP_UPDATE_URL_ANDROID for Google Play.
36
+
37
+ # clubedge:if framework!=none
29
38
  # Where rate limits find each client's address (SETUP.md, "Client addresses"). When only the
30
39
  # frontend's server reaches the API, as over a private network, set TRUSTED_PROXY_HOPS=1: it sends
31
40
  # the client's address as the one X-Forwarded-For entry. Where clients reach the API through your
32
41
  # host instead, set CLIENT_IP_HEADER to the header the host puts their address in.
42
+ # clubedge:end
43
+ # clubedge:if framework=none
44
+ # Where rate limits find each client's address (SETUP.md, "Client addresses"). Behind one proxy
45
+ # that adds it to X-Forwarded-For, such as Caddy with Docker Compose, set TRUSTED_PROXY_HOPS=1,
46
+ # but only when clients reach the API through that proxy alone: one that reaches it directly could
47
+ # claim any address. Where your host puts it in a header of its own, set CLIENT_IP_HEADER to that
48
+ # header instead.
49
+ # clubedge:end
33
50
  CLIENT_IP_HEADER=
34
51
  TRUSTED_PROXY_HOPS=
35
52
 
@@ -41,8 +58,8 @@ SUPABASE_PUBLISHABLE_KEY=
41
58
  SUPABASE_SECRET_KEY=
42
59
  # clubedge:end
43
60
 
44
- # Firebase Authentication. Private key is a server secret; escape newlines as \\n.
45
61
  # clubedge:if auth=firebase
62
+ # Firebase Authentication. Private key is a server secret; escape newlines as \\n.
46
63
  FIREBASE_PROJECT_ID=
47
64
  FIREBASE_API_KEY=
48
65
  FIREBASE_CLIENT_EMAIL=
@@ -50,8 +67,8 @@ FIREBASE_PRIVATE_KEY=
50
67
  # FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
51
68
  # clubedge:end
52
69
 
53
- # Appwrite project settings. Use an API key with the minimum required scopes.
54
70
  # clubedge:if auth=appwrite
71
+ # Appwrite project settings. Use an API key with the minimum required scopes.
55
72
  APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
56
73
  APPWRITE_PROJECT_ID=
57
74
  # Optional: an API key with the sessions.write and users.write scopes, to delete accounts.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/api",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,13 @@ const queue = createMemoryJobs([sampleJob]);
35
35
  const capabilities = ["account"];
36
36
 
37
37
  // Neither app below is given versions, so /api/app-version answers null for each.
38
- const appVersion = { minimumVersion: null, latestVersion: null, updateUrl: null };
38
+ const appVersion = {
39
+ minimumVersion: null,
40
+ latestVersion: null,
41
+ updateUrl: null,
42
+ nextMinimumVersion: null,
43
+ nextMinimumFrom: null,
44
+ };
39
45
 
40
46
  // The shared contract suite, in process: the same checks any API stack passes over HTTP.
41
47
  describe("the API without a database", () => {
@@ -72,3 +78,20 @@ describe("the API with a database", () => {
72
78
  },
73
79
  );
74
80
  });
81
+
82
+ // With a minimum version, the suite also checks that apps below it get 426 update_required.
83
+ describe("the API with a minimum app version", () => {
84
+ const versions = { ...appVersion, minimumVersion: "1.2.0", latestVersion: "1.4.0" };
85
+ const app = createApp({
86
+ serviceId: "my-product",
87
+ isReady: async () => true,
88
+ getCurrentUser: async () => null,
89
+ appVersion: () => versions,
90
+ jobs: { runner: () => "worker", queue: () => queue }, // clubedge:only jobs!=none
91
+ });
92
+ describeApiConformance(
93
+ "hono (minimum app version)",
94
+ () => ({ request: (path, init) => app.request(path, init) }),
95
+ { ready: true, appVersion: versions, modules, capabilities },
96
+ );
97
+ });
@@ -1,6 +1,17 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
- import type { AppVersion, Health, Ready, Session, User } from "@clubedge/contract";
3
+ import {
4
+ APP_PLATFORM_HEADER,
5
+ APP_VERSION_HEADER,
6
+ appPlatform,
7
+ updateStatus,
8
+ type AppPlatform,
9
+ type AppVersion,
10
+ type Health,
11
+ type Ready,
12
+ type Session,
13
+ type User,
14
+ } from "@clubedge/contract";
4
15
  import { reportError, type ClientIpSettings } from "@clubedge/core";
5
16
  import { getClientIp } from "@clubedge/core"; // clubedge:only framework=none && auth=better-auth
6
17
  import { apiError } from "@/http";
@@ -36,8 +47,11 @@ export interface ApiDependencies {
36
47
  getCurrentUser(request: Request): Promise<User | null>;
37
48
  /** Limits for routes anyone can call (src/server/cache); process memory when absent. */
38
49
  createRateLimiter?: CreateRateLimiter;
39
- /** The app versions installed apps check (src/server/app-version); all null when absent. */
40
- appVersion?(): AppVersion;
50
+ /**
51
+ * The app versions installed apps check, for their platform (src/server/app-version); all
52
+ * null when absent, and then no app is refused.
53
+ */
54
+ appVersion?(platform: AppPlatform | null): AppVersion;
41
55
  /** Where those limits find the client's address; the connection's own when absent. */
42
56
  clientIpSettings?: ClientIpSettings;
43
57
  // clubedge:if auth!=none
@@ -62,6 +76,9 @@ export interface ApiDependencies {
62
76
  // clubedge:end
63
77
  }
64
78
 
79
+ /** What an app below the minimum version can still ask: whether the API runs, and where to update. */
80
+ const servedWhileOutdated = new Set(["/api/health", "/api/ready", "/api/app-version"]);
81
+
65
82
  /** The API, implementing packages/contract/openapi.yaml. */
66
83
  export function createApp(dependencies: ApiDependencies) {
67
84
  const app = new Hono();
@@ -76,6 +93,31 @@ export function createApp(dependencies: ApiDependencies) {
76
93
  }),
77
94
  );
78
95
 
96
+ // Apps installed on phones send their version with each request: one below the minimum gets
97
+ // 426 update_required on every route but those it needs to learn that, and where to update,
98
+ // so it shows its update screen whatever it asked.
99
+ app.use("/api/*", async (c, next) => {
100
+ const installed = c.req.header(APP_VERSION_HEADER);
101
+ if (!installed || !dependencies.appVersion || servedWhileOutdated.has(c.req.path)) {
102
+ return next();
103
+ }
104
+ let versions: AppVersion | null = null;
105
+ try {
106
+ versions = dependencies.appVersion(appPlatform(c.req.header(APP_PLATFORM_HEADER)));
107
+ } catch {
108
+ // A mistake in the APP_* variables refuses no app: /api/app-version reports it.
109
+ }
110
+ if (updateStatus(installed, versions) === "required") {
111
+ return apiError(
112
+ c,
113
+ 426,
114
+ "update_required",
115
+ "This version of the app is no longer supported: update it to continue.",
116
+ );
117
+ }
118
+ return next();
119
+ });
120
+
79
121
  app.get("/api/health", (c) =>
80
122
  c.json<Health>({
81
123
  status: "ok",
@@ -6,6 +6,8 @@
6
6
  "workspaceLabel": "Starter workspace",
7
7
  "links": {
8
8
  "repository": "https://github.com/Clubedge/clubedge-starter",
9
- "setupGuide": "https://github.com/Clubedge/clubedge-starter/blob/main/SETUP.md"
9
+ "setupGuide": "https://github.com/Clubedge/clubedge-starter/blob/main/SETUP.md",
10
+ "terms": "https://example.com/terms",
11
+ "privacy": "https://example.com/privacy"
10
12
  }
11
13
  }
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { AppVersion } from "@clubedge/contract";
2
+ import type { AppPlatform, AppVersion } from "@clubedge/contract";
3
3
  import { createApp } from "@/app";
4
4
 
5
- const app = (appVersion?: () => AppVersion) =>
5
+ const app = (appVersion?: (platform: AppPlatform | null) => AppVersion) =>
6
6
  createApp({
7
7
  serviceId: "my-product",
8
8
  isReady: async () => true,
@@ -10,6 +10,14 @@ const app = (appVersion?: () => AppVersion) =>
10
10
  appVersion,
11
11
  });
12
12
 
13
+ const versions: AppVersion = {
14
+ minimumVersion: "1.2.0",
15
+ latestVersion: "1.4.1",
16
+ updateUrl: "https://apps.example.com/my-product",
17
+ nextMinimumVersion: "1.3.0",
18
+ nextMinimumFrom: "2026-11-01T00:00:00.000Z",
19
+ };
20
+
13
21
  describe("/api/app-version", () => {
14
22
  it("answers null versions until the composition root sets them, never cached", async () => {
15
23
  const response = await app().request("/api/app-version");
@@ -19,17 +27,70 @@ describe("/api/app-version", () => {
19
27
  minimumVersion: null,
20
28
  latestVersion: null,
21
29
  updateUrl: null,
30
+ nextMinimumVersion: null,
31
+ nextMinimumFrom: null,
22
32
  });
23
33
  });
24
34
 
25
35
  it("serves the versions from the composition root", async () => {
26
- const versions: AppVersion = {
27
- minimumVersion: "1.2.0",
28
- latestVersion: "1.4.1",
29
- updateUrl: "https://apps.example.com/my-product",
30
- };
31
36
  const response = await app(() => versions).request("/api/app-version");
32
37
  expect(response.status).toBe(200);
33
38
  expect(await response.json()).toEqual(versions);
34
39
  });
40
+
41
+ it("serves the platform the app names its versions", async () => {
42
+ const platforms: Array<AppPlatform | null> = [];
43
+ const api = app((platform) => {
44
+ platforms.push(platform);
45
+ return versions;
46
+ });
47
+ await api.request("/api/app-version", { headers: { "X-App-Platform": "ios" } });
48
+ await api.request("/api/app-version", { headers: { "X-App-Platform": "web" } });
49
+ await api.request("/api/app-version");
50
+ expect(platforms).toEqual(["ios", null, null]);
51
+ });
52
+ });
53
+
54
+ describe("apps below the minimum", () => {
55
+ const api = app(() => versions);
56
+ const from = (version: string) => ({ headers: { "X-App-Version": version } });
57
+
58
+ it("are answered 426 update_required", async () => {
59
+ const response = await api.request("/api/session", from("1.1.9"));
60
+ expect(response.status).toBe(426);
61
+ expect(await response.json()).toEqual({
62
+ error: { code: "update_required", message: expect.any(String) },
63
+ });
64
+ });
65
+
66
+ it("are refused by the minimum of the platform they name", async () => {
67
+ const byPlatform = app((platform) => ({
68
+ ...versions,
69
+ minimumVersion: platform === "ios" ? "1.3.0" : "1.2.0",
70
+ }));
71
+ const on = (platform: string) => ({
72
+ headers: { "X-App-Version": "1.2.5", "X-App-Platform": platform },
73
+ });
74
+ expect((await byPlatform.request("/api/session", on("ios"))).status).toBe(426);
75
+ expect((await byPlatform.request("/api/session", on("android"))).status).toBe(200);
76
+ });
77
+
78
+ it("can still learn whether to update, and where", async () => {
79
+ for (const path of ["/api/health", "/api/ready", "/api/app-version"]) {
80
+ expect((await api.request(path, from("1.1.9"))).status).toBe(200);
81
+ }
82
+ });
83
+
84
+ it("are the only ones refused: apps below an announced minimum are still served", async () => {
85
+ expect((await api.request("/api/session", from("1.2.0"))).status).toBe(200);
86
+ expect((await api.request("/api/session")).status).toBe(200);
87
+ expect((await api.request("/api/session", from("beta"))).status).toBe(200);
88
+ });
89
+
90
+ it("are served while the APP_* variables have a mistake, which /api/app-version reports", async () => {
91
+ const broken = app(() => {
92
+ throw new Error("App version environment validation failed.");
93
+ });
94
+ expect((await broken.request("/api/session", from("1.1.9"))).status).toBe(200);
95
+ });
35
96
  });
@@ -1,16 +1,23 @@
1
1
  // The app versions the API serves, for apps installed on phones: they keep calling the API long
2
2
  // after a release, so they ask here whether they must update, or may.
3
3
  import { Hono } from "hono";
4
- import type { AppVersion } from "@clubedge/contract";
4
+ import { APP_PLATFORM_HEADER, appPlatform, type AppVersion } from "@clubedge/contract";
5
5
  import type { ApiDependencies } from "@/app";
6
6
 
7
7
  /** No version set: no app is asked to update. */
8
- const unset: AppVersion = { minimumVersion: null, latestVersion: null, updateUrl: null };
8
+ const unset: AppVersion = {
9
+ minimumVersion: null,
10
+ latestVersion: null,
11
+ updateUrl: null,
12
+ nextMinimumVersion: null,
13
+ nextMinimumFrom: null,
14
+ };
9
15
 
10
16
  export function appVersionRoutes(dependencies: ApiDependencies) {
11
17
  return new Hono().get("/api/app-version", (c) => {
12
18
  // Never cached, so a new minimum version applies on the app's next check.
13
19
  c.header("Cache-Control", "no-store");
14
- return c.json<AppVersion>(dependencies.appVersion?.() ?? unset);
20
+ const platform = appPlatform(c.req.header(APP_PLATFORM_HEADER));
21
+ return c.json<AppVersion>(dependencies.appVersion?.(platform) ?? unset);
15
22
  });
16
23
  }
@@ -8,12 +8,27 @@ afterEach(() => {
8
8
 
9
9
  const notAVersion = (key: string) => `${key} is not MAJOR.MINOR.PATCH, such as 1.4.0`;
10
10
 
11
+ const unset = {
12
+ minimumVersion: null,
13
+ latestVersion: null,
14
+ updateUrl: null,
15
+ nextMinimumVersion: null,
16
+ nextMinimumFrom: null,
17
+ };
18
+
19
+ const stub = (variables: Record<string, string>) => {
20
+ for (const [key, value] of Object.entries(variables)) vi.stubEnv(key, value);
21
+ };
22
+
11
23
  describe("appVersion", () => {
12
24
  it("reads the versions and where to get the update", () => {
13
- vi.stubEnv("APP_MINIMUM_VERSION", "1.2.0");
14
- vi.stubEnv("APP_LATEST_VERSION", "1.10.0");
15
- vi.stubEnv("APP_UPDATE_URL", "https://apps.example.com/my-product");
25
+ stub({
26
+ APP_MINIMUM_VERSION: "1.2.0",
27
+ APP_LATEST_VERSION: "1.10.0",
28
+ APP_UPDATE_URL: "https://apps.example.com/my-product",
29
+ });
16
30
  expect(appVersion()).toEqual({
31
+ ...unset,
17
32
  minimumVersion: "1.2.0",
18
33
  latestVersion: "1.10.0",
19
34
  updateUrl: "https://apps.example.com/my-product",
@@ -21,10 +36,66 @@ describe("appVersion", () => {
21
36
  });
22
37
 
23
38
  it("answers null for each empty variable, as in a copied .env.example", () => {
24
- vi.stubEnv("APP_MINIMUM_VERSION", "");
25
- vi.stubEnv("APP_LATEST_VERSION", "");
26
- vi.stubEnv("APP_UPDATE_URL", "");
27
- expect(appVersion()).toEqual({ minimumVersion: null, latestVersion: null, updateUrl: null });
39
+ stub({ APP_MINIMUM_VERSION: "", APP_LATEST_VERSION: "", APP_UPDATE_URL: "" });
40
+ expect(appVersion()).toEqual(unset);
41
+ });
42
+
43
+ it("gives a platform its own variables, and the shared ones otherwise", () => {
44
+ stub({
45
+ APP_MINIMUM_VERSION: "1.2.0",
46
+ APP_LATEST_VERSION: "1.4.0",
47
+ APP_LATEST_VERSION_IOS: "1.3.0",
48
+ APP_UPDATE_URL: "https://example.com/download",
49
+ APP_UPDATE_URL_IOS: "https://apps.apple.com/app/id1",
50
+ APP_UPDATE_URL_ANDROID: "https://play.google.com/store/apps/details?id=com.example",
51
+ });
52
+ expect(appVersion("ios")).toMatchObject({
53
+ minimumVersion: "1.2.0",
54
+ latestVersion: "1.3.0",
55
+ updateUrl: "https://apps.apple.com/app/id1",
56
+ });
57
+ expect(appVersion("android")).toMatchObject({
58
+ latestVersion: "1.4.0",
59
+ updateUrl: "https://play.google.com/store/apps/details?id=com.example",
60
+ });
61
+ expect(appVersion(null)).toMatchObject({
62
+ latestVersion: "1.4.0",
63
+ updateUrl: "https://example.com/download",
64
+ });
65
+ });
66
+
67
+ describe("an announced minimum", () => {
68
+ const announced = {
69
+ APP_MINIMUM_VERSION: "1.0.0",
70
+ APP_NEXT_MINIMUM_VERSION: "1.2.0",
71
+ APP_NEXT_MINIMUM_VERSION_FROM: "2026-11-01T01:00:00+01:00",
72
+ };
73
+ const deadline = new Date("2026-11-01T00:00:00Z");
74
+
75
+ it("is announced until its date, in UTC", () => {
76
+ stub(announced);
77
+ expect(appVersion(null, new Date(deadline.getTime() - 1))).toEqual({
78
+ ...unset,
79
+ minimumVersion: "1.0.0",
80
+ nextMinimumVersion: "1.2.0",
81
+ nextMinimumFrom: "2026-11-01T00:00:00.000Z",
82
+ });
83
+ });
84
+
85
+ it("becomes the minimum on its date, with no redeploy", () => {
86
+ stub(announced);
87
+ expect(appVersion(null, deadline)).toEqual({ ...unset, minimumVersion: "1.2.0" });
88
+ });
89
+
90
+ it("can be one platform's", () => {
91
+ stub({
92
+ APP_MINIMUM_VERSION: "1.0.0",
93
+ APP_NEXT_MINIMUM_VERSION_ANDROID: "1.2.0",
94
+ APP_NEXT_MINIMUM_VERSION_FROM_ANDROID: "2026-11-01T00:00:00Z",
95
+ });
96
+ expect(appVersion("android", deadline).minimumVersion).toBe("1.2.0");
97
+ expect(appVersion("ios", deadline).minimumVersion).toBe("1.0.0");
98
+ });
28
99
  });
29
100
 
30
101
  // The route then fails, and is reported, rather than telling the apps something wrong.
@@ -47,11 +118,15 @@ describe("appVersionProblems", () => {
47
118
  APP_MINIMUM_VERSION: "1.2",
48
119
  APP_LATEST_VERSION: "v1.4.0",
49
120
  APP_UPDATE_URL: "http://apps.example.com/my-product",
121
+ APP_LATEST_VERSION_IOS: "1.4",
122
+ APP_NEXT_MINIMUM_VERSION_FROM: "2026-11-01",
50
123
  }),
51
124
  ).toEqual([
52
125
  notAVersion("APP_MINIMUM_VERSION"),
53
126
  notAVersion("APP_LATEST_VERSION"),
127
+ "APP_NEXT_MINIMUM_VERSION_FROM is not a date and time with its time zone, such as 2026-11-01T00:00:00Z",
54
128
  "APP_UPDATE_URL is not an https:// URL",
129
+ notAVersion("APP_LATEST_VERSION_IOS"),
55
130
  ]);
56
131
  });
57
132
 
@@ -64,4 +139,31 @@ describe("appVersionProblems", () => {
64
139
  "APP_LATEST_VERSION is older than APP_MINIMUM_VERSION",
65
140
  ]);
66
141
  });
142
+
143
+ it("checks a platform's own versions against the ones it shares", () => {
144
+ expect(
145
+ appVersionProblems({ APP_MINIMUM_VERSION: "1.2.0", APP_LATEST_VERSION_IOS: "1.1.0" }),
146
+ ).toEqual(["APP_LATEST_VERSION_IOS is older than APP_MINIMUM_VERSION"]);
147
+ });
148
+
149
+ it("checks an announced minimum", () => {
150
+ const from = "2026-11-01T00:00:00Z";
151
+ expect(appVersionProblems({ APP_NEXT_MINIMUM_VERSION: "1.2.0" })).toEqual([
152
+ "APP_NEXT_MINIMUM_VERSION and APP_NEXT_MINIMUM_VERSION_FROM go together: set both, or neither",
153
+ ]);
154
+ expect(
155
+ appVersionProblems({
156
+ APP_MINIMUM_VERSION: "1.2.0",
157
+ APP_NEXT_MINIMUM_VERSION: "1.2.0",
158
+ APP_NEXT_MINIMUM_VERSION_FROM: from,
159
+ }),
160
+ ).toEqual(["APP_NEXT_MINIMUM_VERSION is not newer than APP_MINIMUM_VERSION"]);
161
+ expect(
162
+ appVersionProblems({
163
+ APP_LATEST_VERSION: "1.1.0",
164
+ APP_NEXT_MINIMUM_VERSION: "1.2.0",
165
+ APP_NEXT_MINIMUM_VERSION_FROM: from,
166
+ }),
167
+ ).toEqual(["APP_LATEST_VERSION is older than APP_NEXT_MINIMUM_VERSION"]);
168
+ });
67
169
  });
@@ -1,57 +1,107 @@
1
1
  import { z } from "zod";
2
- import type { AppVersion } from "@clubedge/contract";
3
- import { optional, parseEnv } from "@/env";
2
+ import { appPlatforms, isOlder, type AppPlatform, type AppVersion } from "@clubedge/contract";
3
+ import { optional } from "@/env";
4
+
5
+ // What /api/app-version tells the apps installed on phones, from the APP_* variables. Each can
6
+ // be set for one platform by adding _IOS or _ANDROID to its name: that platform's value then
7
+ // wins, for apps that name it (X-App-Platform). Unset, each is null.
4
8
 
5
- /** The variable `key`, holding a MAJOR.MINOR.PATCH version such as 1.4.0. */
6
9
  const versionVariable = (key: string) =>
7
10
  optional(z.string().regex(/^\d+\.\d+\.\d+$/, `${key} is not MAJOR.MINOR.PATCH, such as 1.4.0`));
11
+ const urlVariable = (key: string) =>
12
+ optional(z.url({ protocol: /^https$/, error: `${key} is not an https:// URL` }));
13
+ const dateVariable = (key: string) =>
14
+ optional(
15
+ z.iso.datetime({
16
+ offset: true,
17
+ error: `${key} is not a date and time with its time zone, such as 2026-11-01T00:00:00Z`,
18
+ }),
19
+ );
8
20
 
9
- /** Whether `version` is older than `than`, number by number: 1.9.0 is older than 1.10.0. */
10
- function isOlder(version: string, than: string): boolean {
11
- const a = version.split(".").map(Number);
12
- const b = than.split(".").map(Number);
13
- const index = a.findIndex((part, i) => part !== b[i]);
14
- return index !== -1 && a[index] < b[index];
15
- }
21
+ const fields = {
22
+ // The oldest version the API still serves: older apps must update before they continue.
23
+ APP_MINIMUM_VERSION: versionVariable,
24
+ // The newest released version: older apps may say an update is out.
25
+ APP_LATEST_VERSION: versionVariable,
26
+ // A minimum announced for a date: older apps keep working until then, and warn their users.
27
+ APP_NEXT_MINIMUM_VERSION: versionVariable,
28
+ APP_NEXT_MINIMUM_VERSION_FROM: dateVariable,
29
+ // Where apps get the update, such as their store listing.
30
+ APP_UPDATE_URL: urlVariable,
31
+ };
32
+ type Field = keyof typeof fields;
16
33
 
17
- // What /api/app-version tells the apps installed on phones. Unset, each is null.
18
- const appVersionEnvSchema = z
19
- .object({
20
- // The oldest version the API still serves: older apps must update before they continue.
21
- APP_MINIMUM_VERSION: versionVariable("APP_MINIMUM_VERSION"),
22
- // The newest released version: older apps may show an update notice.
23
- APP_LATEST_VERSION: versionVariable("APP_LATEST_VERSION"),
24
- // Where apps get the update, such as their store listing.
25
- APP_UPDATE_URL: optional(
26
- z.url({ protocol: /^https$/, error: "APP_UPDATE_URL is not an https:// URL" }),
34
+ const suffixes = ["", ...appPlatforms.map((platform) => `_${platform.toUpperCase()}`)];
35
+ const formats = z.object(
36
+ Object.fromEntries(
37
+ suffixes.flatMap((suffix) =>
38
+ Object.entries(fields).map(([key, schema]) => [key + suffix, schema(key + suffix)]),
27
39
  ),
28
- })
29
- .refine(
30
- ({ APP_MINIMUM_VERSION: minimum, APP_LATEST_VERSION: latest }) =>
31
- !minimum || !latest || !isOlder(latest, minimum),
32
- {
33
- path: ["APP_LATEST_VERSION"],
34
- error: "APP_LATEST_VERSION is older than APP_MINIMUM_VERSION",
35
- // Checked once the rest is valid, so it compares two versions.
36
- when: ({ issues }) => issues.length === 0,
37
- },
38
- );
40
+ ),
41
+ );
42
+
43
+ type Env = Record<string, string | undefined>;
44
+
45
+ /** A variable's value for `platform`, its own when set, else the shared one, and its name. */
46
+ function setting(env: Env, field: Field, platform: AppPlatform | null) {
47
+ const own = platform && `${field}_${platform.toUpperCase()}`;
48
+ const ownValue = own ? env[own]?.trim() : "";
49
+ if (own && ownValue) return { key: own, value: ownValue };
50
+ return { key: field, value: env[field]?.trim() || null };
51
+ }
52
+
53
+ /** The versions that contradict each other, for each platform and for apps that name none. */
54
+ function inconsistencies(env: Env): string[] {
55
+ const problems = new Set<string>();
56
+ for (const platform of [null, ...appPlatforms]) {
57
+ const minimum = setting(env, "APP_MINIMUM_VERSION", platform);
58
+ const latest = setting(env, "APP_LATEST_VERSION", platform);
59
+ const next = setting(env, "APP_NEXT_MINIMUM_VERSION", platform);
60
+ const from = setting(env, "APP_NEXT_MINIMUM_VERSION_FROM", platform);
61
+ if (minimum.value && latest.value && isOlder(latest.value, minimum.value)) {
62
+ problems.add(`${latest.key} is older than ${minimum.key}`);
63
+ }
64
+ if (!next.value !== !from.value) {
65
+ problems.add(`${next.key} and ${from.key} go together: set both, or neither`);
66
+ }
67
+ if (next.value && minimum.value && !isOlder(minimum.value, next.value)) {
68
+ problems.add(`${next.key} is not newer than ${minimum.key}`);
69
+ }
70
+ if (next.value && latest.value && isOlder(latest.value, next.value)) {
71
+ problems.add(`${latest.key} is older than ${next.key}`);
72
+ }
73
+ }
74
+ return [...problems];
75
+ }
76
+
77
+ /** The mistakes in the APP_* variables of `env`, which `pnpm check:env` lists before a deploy. */
78
+ export function appVersionProblems(env: Env): string[] {
79
+ const parsed = formats.safeParse(env);
80
+ if (!parsed.success) return parsed.error.issues.map(({ message }) => message);
81
+ // Checked once each is valid, so they compare versions.
82
+ return inconsistencies(env);
83
+ }
39
84
 
40
85
  /**
41
- * The versions, read on each request: a mistake in the variables fails the request, and is
42
- * reported, rather than telling the apps something wrong.
86
+ * The versions for apps on `platform` at `now`, read on each request: a mistake in the
87
+ * variables fails the request, and is reported, rather than telling the apps something wrong.
88
+ * On its date, the announced minimum becomes the one the API requires, with no redeploy.
43
89
  */
44
- export function appVersion(): AppVersion {
45
- const env = parseEnv(appVersionEnvSchema, "App version");
90
+ export function appVersion(platform: AppPlatform | null = null, now = new Date()): AppVersion {
91
+ const problems = appVersionProblems(process.env);
92
+ if (problems.length > 0) {
93
+ console.error("Invalid App version environment:", problems);
94
+ throw new Error("App version environment validation failed. Check your .env.local file.");
95
+ }
96
+ const value = (field: Field) => setting(process.env, field, platform).value;
97
+ const next = value("APP_NEXT_MINIMUM_VERSION");
98
+ const from = value("APP_NEXT_MINIMUM_VERSION_FROM");
99
+ const due = !!next && !!from && Date.parse(from) <= now.getTime();
46
100
  return {
47
- minimumVersion: env.APP_MINIMUM_VERSION || null,
48
- latestVersion: env.APP_LATEST_VERSION || null,
49
- updateUrl: env.APP_UPDATE_URL || null,
101
+ minimumVersion: due ? next : value("APP_MINIMUM_VERSION"),
102
+ latestVersion: value("APP_LATEST_VERSION"),
103
+ updateUrl: value("APP_UPDATE_URL"),
104
+ nextMinimumVersion: due ? null : next,
105
+ nextMinimumFrom: due || !from ? null : new Date(from).toISOString(),
50
106
  };
51
107
  }
52
-
53
- /** The mistakes in the APP_* variables of `env`, which `pnpm check:env` lists before a deploy. */
54
- export function appVersionProblems(env: Record<string, string | undefined>): string[] {
55
- const parsed = appVersionEnvSchema.safeParse(env);
56
- return parsed.success ? [] : parsed.error.issues.map(({ message }) => message);
57
- }