@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.
- package/CHANGELOG.md +13 -0
- package/README.md +12 -10
- package/dist/core/merge.js +26 -12
- package/dist/core/package-json-merge.js +106 -0
- package/package.json +3 -3
- package/template/.github/workflows/ci.yml +1 -1
- package/template/.railway/railway.ts +9 -0
- package/template/README.md +24 -15
- package/template/SETUP.md +49 -23
- package/template/apps/api/.env.example +22 -5
- package/template/apps/api/package.json +1 -1
- package/template/apps/api/src/app.test.ts +24 -1
- package/template/apps/api/src/app.ts +45 -3
- package/template/apps/api/src/config/site.json +3 -1
- package/template/apps/api/src/routes/app-version.test.ts +68 -7
- package/template/apps/api/src/routes/app-version.ts +10 -3
- package/template/apps/api/src/server/app-version.test.ts +109 -7
- package/template/apps/api/src/server/app-version.ts +94 -44
- package/template/apps/api-fastapi/.env.example +26 -9
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +1 -1
- package/template/apps/api-fastapi/src/clubedge_api/app.py +41 -3
- package/template/apps/api-fastapi/src/clubedge_api/app_version.py +160 -28
- package/template/apps/api-fastapi/src/clubedge_api/contract.py +4 -0
- package/template/apps/api-fastapi/src/clubedge_api/routes/app_version.py +17 -8
- package/template/apps/api-fastapi/src/config/site.json +3 -1
- package/template/apps/api-fastapi/tests/test_app_version.py +247 -20
- package/template/apps/api-fastapi/uv.lock +1 -1
- package/template/apps/capacitor/assets/splash-dark.svg +1 -0
- package/template/apps/capacitor/assets/splash-icon.png +0 -0
- package/template/apps/capacitor/assets/splash.svg +1 -0
- package/template/apps/capacitor/package.json +1 -1
- package/template/apps/capacitor/scripts/icons.mjs +113 -14
- package/template/apps/expo/app.config.ts +4 -1
- package/template/apps/expo/clubedge.stack.json +6 -1
- package/template/apps/expo/package.json +7 -2
- package/template/apps/expo/src/app/(app)/_layout.tsx +4 -23
- package/template/apps/expo/src/app/(app)/index.tsx +164 -44
- package/template/apps/expo/src/app/(app)/settings/_layout.tsx +12 -0
- package/template/apps/expo/src/app/(app)/settings/delete-account.tsx +76 -0
- package/template/apps/expo/src/app/(app)/settings/edit-profile.tsx +44 -0
- package/template/apps/expo/src/app/(app)/settings/index.tsx +90 -0
- package/template/apps/expo/src/app/(app)/settings/password.tsx +49 -0
- package/template/apps/expo/src/app/_layout.tsx +12 -1
- package/template/apps/expo/src/app/forgot-password.tsx +47 -39
- package/template/apps/expo/src/app/reset-password.tsx +6 -8
- package/template/apps/expo/src/app/sign-in.tsx +29 -12
- package/template/apps/expo/src/app/sign-up.tsx +42 -14
- package/template/apps/expo/src/app/welcome.tsx +99 -0
- package/template/apps/expo/src/components/sign-out-sheet.tsx +138 -0
- package/template/apps/expo/src/components/tab-bar.tsx +143 -0
- package/template/apps/expo/src/components/ui.tsx +545 -81
- package/template/apps/expo/src/components/update-gate.tsx +327 -37
- package/template/apps/expo/src/config/site.json +3 -1
- package/template/apps/expo/src/lib/account.ts +62 -0
- package/template/apps/expo/src/lib/app-update.ts +36 -0
- package/template/apps/expo/src/lib/auth-client.ts +10 -2
- package/template/apps/expo/src/lib/theme.ts +152 -15
- package/template/apps/expo/src/types/images.d.ts +13 -0
- package/template/apps/expo/src/variants/app-layout.auth.tsx +17 -0
- package/template/apps/expo/src/variants/auth.better-auth.ts +12 -2
- package/template/apps/expo/src/variants/auth.supabase.ts +8 -5
- package/template/apps/expo/src/variants/layout.auth.tsx +24 -13
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/src/config/site.json +3 -1
- package/template/apps/vite/.env.example +4 -4
- package/template/apps/vite/package.json +1 -1
- package/template/apps/vite/server.mjs +2 -1
- package/template/apps/vite/src/config/site.json +3 -1
- package/template/apps/vite/src/main.tsx +4 -3
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/config/site.json +3 -1
- package/template/clubedge.template.json +1 -1
- package/template/package.json +3 -2
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/openapi.yaml +85 -3
- package/template/packages/contract/package.json +1 -1
- package/template/packages/contract/src/app-update.test.ts +321 -0
- package/template/packages/contract/src/app-update.ts +237 -0
- package/template/packages/contract/src/app-version.test.ts +76 -0
- package/template/packages/contract/src/app-version.ts +71 -0
- package/template/packages/contract/src/conformance.ts +42 -1
- package/template/packages/contract/src/index.ts +2 -0
- package/template/packages/contract/src/schema.ts +44 -4
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-bullmq/package.json +1 -1
- package/template/packages/jobs-inngest/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/mobile/package.json +1 -1
- package/template/packages/mobile/src/demo/api.ts +7 -1
- package/template/packages/mobile/src/demo/demo.test.ts +2 -0
- package/template/packages/mobile/src/update-notice.test.ts +26 -2
- package/template/packages/mobile/src/update-notice.tsx +55 -41
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +146 -5
- package/template/supabase/config.toml +10 -0
- package/template.lock.json +21 -7
- package/template/apps/expo/src/app/(app)/settings.tsx +0 -168
- package/template/apps/expo/src/lib/update.test.ts +0 -34
- package/template/apps/expo/src/lib/update.ts +0 -40
- package/template/packages/mobile/src/version.test.ts +0 -65
- 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
|
|
24
|
-
# may show an update notice. The update URL is an https://
|
|
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.
|
|
@@ -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 = {
|
|
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
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
3
|
-
import { optional
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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:
|
|
48
|
-
latestVersion:
|
|
49
|
-
updateUrl:
|
|
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
|
-
}
|