@ramonclaudio/create-vexpo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RESERVED_USERNAMES,
|
|
5
|
+
USERNAME_MAX_LENGTH,
|
|
6
|
+
USERNAME_MIN_LENGTH,
|
|
7
|
+
isReservedUsername,
|
|
8
|
+
isValidUsernameFormat,
|
|
9
|
+
} from "@/convex/constants";
|
|
10
|
+
|
|
11
|
+
describe("isValidUsernameFormat", () => {
|
|
12
|
+
test("accepts alphanumerics, dots, underscores", () => {
|
|
13
|
+
expect(isValidUsernameFormat("ray")).toBe(true);
|
|
14
|
+
expect(isValidUsernameFormat("ray_claudio")).toBe(true);
|
|
15
|
+
expect(isValidUsernameFormat("ray.claudio")).toBe(true);
|
|
16
|
+
expect(isValidUsernameFormat("Ray123")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects strings shorter than the min length", () => {
|
|
20
|
+
expect(USERNAME_MIN_LENGTH).toBe(3);
|
|
21
|
+
expect(isValidUsernameFormat("ab")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects strings longer than the max length", () => {
|
|
25
|
+
expect(USERNAME_MAX_LENGTH).toBe(30);
|
|
26
|
+
expect(isValidUsernameFormat("a".repeat(31))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects characters outside the allowed set", () => {
|
|
30
|
+
expect(isValidUsernameFormat("ray-claudio")).toBe(false);
|
|
31
|
+
expect(isValidUsernameFormat("ray claudio")).toBe(false);
|
|
32
|
+
expect(isValidUsernameFormat("ray@claudio")).toBe(false);
|
|
33
|
+
expect(isValidUsernameFormat("rayé")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("isReservedUsername", () => {
|
|
38
|
+
test("matches every name on the reserved list, case-insensitive", () => {
|
|
39
|
+
for (const name of RESERVED_USERNAMES) {
|
|
40
|
+
expect(isReservedUsername(name)).toBe(true);
|
|
41
|
+
expect(isReservedUsername(name.toUpperCase())).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not match a normal username", () => {
|
|
46
|
+
expect(isReservedUsername("ray")).toBe(false);
|
|
47
|
+
expect(isReservedUsername("ramon")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { validateBio } from "@/convex/validators";
|
|
4
|
+
|
|
5
|
+
describe("validateBio", () => {
|
|
6
|
+
test("accepts empty string", () => {
|
|
7
|
+
expect(validateBio("")).toEqual({ valid: true });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("accepts a normal bio", () => {
|
|
11
|
+
expect(validateBio("Building things on the internet.")).toEqual({ valid: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("accepts exactly 500 characters", () => {
|
|
15
|
+
expect(validateBio("a".repeat(500))).toEqual({ valid: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rejects bios over 500 characters", () => {
|
|
19
|
+
const result = validateBio("a".repeat(501));
|
|
20
|
+
expect(result.valid).toBe(false);
|
|
21
|
+
expect(result.error).toMatch(/500 characters or less/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { withWebhook } from "@/convex/webhook";
|
|
4
|
+
|
|
5
|
+
// Stub Convex action context. The webhook factory doesn't use it (the inner
|
|
6
|
+
// handler does, if it needs to call queries/mutations), so an empty object
|
|
7
|
+
// satisfies the type for these unit tests. Cast through `unknown` rather than
|
|
8
|
+
// importing the Convex generic action type, which would bloat the test deps.
|
|
9
|
+
const ctx = {} as unknown as Parameters<ReturnType<typeof withWebhook>>[0];
|
|
10
|
+
|
|
11
|
+
async function sign(algorithm: "sha1" | "sha256", secret: string, body: string): Promise<string> {
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const key = await crypto.subtle.importKey(
|
|
14
|
+
"raw",
|
|
15
|
+
enc.encode(secret),
|
|
16
|
+
{ name: "HMAC", hash: algorithm === "sha1" ? "SHA-1" : "SHA-256" },
|
|
17
|
+
false,
|
|
18
|
+
["sign"],
|
|
19
|
+
);
|
|
20
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
21
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeRequest(opts: {
|
|
25
|
+
body: string;
|
|
26
|
+
signatureHeader: string;
|
|
27
|
+
signatureValue?: string;
|
|
28
|
+
timestampHeader?: string;
|
|
29
|
+
timestampValue?: string;
|
|
30
|
+
contentLength?: number;
|
|
31
|
+
}): Request {
|
|
32
|
+
const headers: Record<string, string> = {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
};
|
|
35
|
+
if (opts.signatureValue) headers[opts.signatureHeader] = opts.signatureValue;
|
|
36
|
+
if (opts.timestampHeader && opts.timestampValue)
|
|
37
|
+
headers[opts.timestampHeader] = opts.timestampValue;
|
|
38
|
+
if (opts.contentLength !== undefined) headers["Content-Length"] = String(opts.contentLength);
|
|
39
|
+
return new Request("https://example.convex.site/webhook", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers,
|
|
42
|
+
body: opts.body,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("withWebhook (HMAC signature verification)", () => {
|
|
47
|
+
const SECRET = "test-secret-do-not-rotate";
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
process.env.TEST_WEBHOOK_SECRET = SECRET;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
delete process.env.TEST_WEBHOOK_SECRET;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("503 when secret env var is unset", async () => {
|
|
58
|
+
delete process.env.TEST_WEBHOOK_SECRET;
|
|
59
|
+
const handler = withWebhook(
|
|
60
|
+
{
|
|
61
|
+
source: "test",
|
|
62
|
+
signatureHeader: "x-signature",
|
|
63
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
64
|
+
algorithm: "sha256",
|
|
65
|
+
},
|
|
66
|
+
() => new Response("ok"),
|
|
67
|
+
);
|
|
68
|
+
const res = await handler(ctx, makeRequest({ body: "{}", signatureHeader: "x-signature" }));
|
|
69
|
+
expect(res.status).toBe(503);
|
|
70
|
+
expect(res.headers.get("X-Request-Id")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("401 when signature header is missing", async () => {
|
|
74
|
+
const handler = withWebhook(
|
|
75
|
+
{
|
|
76
|
+
source: "test",
|
|
77
|
+
signatureHeader: "x-signature",
|
|
78
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
79
|
+
algorithm: "sha256",
|
|
80
|
+
},
|
|
81
|
+
() => new Response("ok"),
|
|
82
|
+
);
|
|
83
|
+
const res = await handler(ctx, makeRequest({ body: "{}", signatureHeader: "x-signature" }));
|
|
84
|
+
expect(res.status).toBe(401);
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
expect(text).toContain("missing x-signature");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("401 when signature does not match", async () => {
|
|
90
|
+
const handler = withWebhook(
|
|
91
|
+
{
|
|
92
|
+
source: "test",
|
|
93
|
+
signatureHeader: "x-signature",
|
|
94
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
95
|
+
algorithm: "sha256",
|
|
96
|
+
},
|
|
97
|
+
() => new Response("ok"),
|
|
98
|
+
);
|
|
99
|
+
const res = await handler(
|
|
100
|
+
ctx,
|
|
101
|
+
makeRequest({
|
|
102
|
+
body: "{}",
|
|
103
|
+
signatureHeader: "x-signature",
|
|
104
|
+
signatureValue: "deadbeef",
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
expect(res.status).toBe(401);
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
expect(text).toContain("signature mismatch");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("200 when signature matches (SHA-256)", async () => {
|
|
113
|
+
const body = JSON.stringify({ event: "test.ping" });
|
|
114
|
+
const signature = await sign("sha256", SECRET, body);
|
|
115
|
+
let handlerCalled = false;
|
|
116
|
+
const handler = withWebhook<{ event: string }>(
|
|
117
|
+
{
|
|
118
|
+
source: "test",
|
|
119
|
+
signatureHeader: "x-signature",
|
|
120
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
121
|
+
algorithm: "sha256",
|
|
122
|
+
},
|
|
123
|
+
(_ctx, payload) => {
|
|
124
|
+
handlerCalled = true;
|
|
125
|
+
return new Response(JSON.stringify({ ok: true, received: payload.event }), { status: 200 });
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
const res = await handler(
|
|
129
|
+
ctx,
|
|
130
|
+
makeRequest({
|
|
131
|
+
body,
|
|
132
|
+
signatureHeader: "x-signature",
|
|
133
|
+
signatureValue: signature,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
expect(res.status).toBe(200);
|
|
137
|
+
expect(handlerCalled).toBe(true);
|
|
138
|
+
const json = (await res.json()) as { ok: boolean; received: string };
|
|
139
|
+
expect(json.received).toBe("test.ping");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("200 when signature matches with prefix (SHA-1, EAS-style)", async () => {
|
|
143
|
+
const body = JSON.stringify({ status: "finished" });
|
|
144
|
+
const signature = `sha1=${await sign("sha1", SECRET, body)}`;
|
|
145
|
+
const handler = withWebhook(
|
|
146
|
+
{
|
|
147
|
+
source: "eas-webhook",
|
|
148
|
+
signatureHeader: "expo-signature",
|
|
149
|
+
signaturePrefix: "sha1=",
|
|
150
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
151
|
+
algorithm: "sha1",
|
|
152
|
+
},
|
|
153
|
+
() => new Response("ok", { status: 200 }),
|
|
154
|
+
);
|
|
155
|
+
const res = await handler(
|
|
156
|
+
ctx,
|
|
157
|
+
makeRequest({
|
|
158
|
+
body,
|
|
159
|
+
signatureHeader: "expo-signature",
|
|
160
|
+
signatureValue: signature,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
expect(res.status).toBe(200);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("400 when body is not valid JSON", async () => {
|
|
167
|
+
const body = "not-json{";
|
|
168
|
+
const signature = await sign("sha256", SECRET, body);
|
|
169
|
+
const handler = withWebhook(
|
|
170
|
+
{
|
|
171
|
+
source: "test",
|
|
172
|
+
signatureHeader: "x-signature",
|
|
173
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
174
|
+
algorithm: "sha256",
|
|
175
|
+
},
|
|
176
|
+
() => new Response("ok"),
|
|
177
|
+
);
|
|
178
|
+
const res = await handler(
|
|
179
|
+
ctx,
|
|
180
|
+
makeRequest({
|
|
181
|
+
body,
|
|
182
|
+
signatureHeader: "x-signature",
|
|
183
|
+
signatureValue: signature,
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
expect(res.status).toBe(400);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("413 when Content-Length exceeds maxBodyBytes", async () => {
|
|
190
|
+
const handler = withWebhook(
|
|
191
|
+
{
|
|
192
|
+
source: "test",
|
|
193
|
+
signatureHeader: "x-signature",
|
|
194
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
195
|
+
algorithm: "sha256",
|
|
196
|
+
maxBodyBytes: 100,
|
|
197
|
+
},
|
|
198
|
+
() => new Response("ok"),
|
|
199
|
+
);
|
|
200
|
+
const res = await handler(
|
|
201
|
+
ctx,
|
|
202
|
+
makeRequest({
|
|
203
|
+
body: "{}",
|
|
204
|
+
signatureHeader: "x-signature",
|
|
205
|
+
signatureValue: "deadbeef",
|
|
206
|
+
contentLength: 999_999,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
expect(res.status).toBe(413);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("401 when replay timestamp is missing", async () => {
|
|
213
|
+
const handler = withWebhook(
|
|
214
|
+
{
|
|
215
|
+
source: "test",
|
|
216
|
+
signatureHeader: "x-signature",
|
|
217
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
218
|
+
algorithm: "sha256",
|
|
219
|
+
replay: { header: "x-timestamp", maxAgeSeconds: 300 },
|
|
220
|
+
},
|
|
221
|
+
() => new Response("ok"),
|
|
222
|
+
);
|
|
223
|
+
const body = "{}";
|
|
224
|
+
const signature = await sign("sha256", SECRET, body);
|
|
225
|
+
const res = await handler(
|
|
226
|
+
ctx,
|
|
227
|
+
makeRequest({
|
|
228
|
+
body,
|
|
229
|
+
signatureHeader: "x-signature",
|
|
230
|
+
signatureValue: signature,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
expect(res.status).toBe(401);
|
|
234
|
+
const text = await res.text();
|
|
235
|
+
expect(text).toContain("missing x-timestamp");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("401 when replay timestamp is stale", async () => {
|
|
239
|
+
const handler = withWebhook(
|
|
240
|
+
{
|
|
241
|
+
source: "test",
|
|
242
|
+
signatureHeader: "x-signature",
|
|
243
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
244
|
+
algorithm: "sha256",
|
|
245
|
+
replay: { header: "x-timestamp", maxAgeSeconds: 60 },
|
|
246
|
+
},
|
|
247
|
+
() => new Response("ok"),
|
|
248
|
+
);
|
|
249
|
+
const body = "{}";
|
|
250
|
+
const signature = await sign("sha256", SECRET, body);
|
|
251
|
+
const stale = Date.now() - 120_000; // 2 minutes ago, exceeds 60s window
|
|
252
|
+
const res = await handler(
|
|
253
|
+
ctx,
|
|
254
|
+
makeRequest({
|
|
255
|
+
body,
|
|
256
|
+
signatureHeader: "x-signature",
|
|
257
|
+
signatureValue: signature,
|
|
258
|
+
timestampHeader: "x-timestamp",
|
|
259
|
+
timestampValue: String(stale),
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
expect(res.status).toBe(401);
|
|
263
|
+
const text = await res.text();
|
|
264
|
+
expect(text).toContain("timestamp out of window");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("200 when replay timestamp is fresh", async () => {
|
|
268
|
+
const handler = withWebhook(
|
|
269
|
+
{
|
|
270
|
+
source: "test",
|
|
271
|
+
signatureHeader: "x-signature",
|
|
272
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
273
|
+
algorithm: "sha256",
|
|
274
|
+
replay: { header: "x-timestamp", maxAgeSeconds: 300 },
|
|
275
|
+
},
|
|
276
|
+
() => new Response("ok", { status: 200 }),
|
|
277
|
+
);
|
|
278
|
+
const body = "{}";
|
|
279
|
+
const signature = await sign("sha256", SECRET, body);
|
|
280
|
+
const res = await handler(
|
|
281
|
+
ctx,
|
|
282
|
+
makeRequest({
|
|
283
|
+
body,
|
|
284
|
+
signatureHeader: "x-signature",
|
|
285
|
+
signatureValue: signature,
|
|
286
|
+
timestampHeader: "x-timestamp",
|
|
287
|
+
timestampValue: String(Date.now()),
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
expect(res.status).toBe(200);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("X-Request-Id header is set on every response", async () => {
|
|
294
|
+
const handler = withWebhook(
|
|
295
|
+
{
|
|
296
|
+
source: "test",
|
|
297
|
+
signatureHeader: "x-signature",
|
|
298
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
299
|
+
algorithm: "sha256",
|
|
300
|
+
},
|
|
301
|
+
() => new Response("ok", { status: 200 }),
|
|
302
|
+
);
|
|
303
|
+
const body = "{}";
|
|
304
|
+
const signature = await sign("sha256", SECRET, body);
|
|
305
|
+
const res = await handler(
|
|
306
|
+
ctx,
|
|
307
|
+
makeRequest({
|
|
308
|
+
body,
|
|
309
|
+
signatureHeader: "x-signature",
|
|
310
|
+
signatureValue: signature,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
expect(res.status).toBe(200);
|
|
314
|
+
expect(res.headers.get("X-Request-Id")).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("500 when handler throws", async () => {
|
|
318
|
+
const handler = withWebhook(
|
|
319
|
+
{
|
|
320
|
+
source: "test",
|
|
321
|
+
signatureHeader: "x-signature",
|
|
322
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
323
|
+
algorithm: "sha256",
|
|
324
|
+
},
|
|
325
|
+
() => {
|
|
326
|
+
throw new Error("simulated handler crash");
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
const body = "{}";
|
|
330
|
+
const signature = await sign("sha256", SECRET, body);
|
|
331
|
+
const res = await handler(
|
|
332
|
+
ctx,
|
|
333
|
+
makeRequest({
|
|
334
|
+
body,
|
|
335
|
+
signatureHeader: "x-signature",
|
|
336
|
+
signatureValue: signature,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
expect(res.status).toBe(500);
|
|
340
|
+
const text = await res.text();
|
|
341
|
+
expect(text).toContain("handler error");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("expo-linking", () => {
|
|
4
|
+
function parse(url: string) {
|
|
5
|
+
try {
|
|
6
|
+
const u = new URL(url);
|
|
7
|
+
const queryParams: Record<string, string> = {};
|
|
8
|
+
u.searchParams.forEach((v, k) => {
|
|
9
|
+
queryParams[k] = v;
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
scheme: u.protocol.replace(/:$/, ""),
|
|
13
|
+
hostname: u.hostname || null,
|
|
14
|
+
path: u.pathname || null,
|
|
15
|
+
queryParams,
|
|
16
|
+
};
|
|
17
|
+
} catch {
|
|
18
|
+
return { scheme: null, hostname: null, path: url, queryParams: null };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { parse, createURL: (p: string) => p };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { resolveDeepLink } from "@/lib/deep-link";
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("resolveDeepLink", () => {
|
|
31
|
+
it("parses a valid path with query params", () => {
|
|
32
|
+
const result = resolveDeepLink("vexpo://app/linked?foo=bar&n=1");
|
|
33
|
+
expect(result.path).toBe("/linked");
|
|
34
|
+
expect(result.params).toEqual({ foo: "bar", n: "1" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null path for disallowed routes", () => {
|
|
38
|
+
const result = resolveDeepLink("vexpo://app/admin");
|
|
39
|
+
expect(result.path).toBeNull();
|
|
40
|
+
expect(result.params).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null path for path traversal attempts", () => {
|
|
44
|
+
const result = resolveDeepLink("vexpo://app/../etc/passwd");
|
|
45
|
+
expect(result.path).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles empty input", () => {
|
|
49
|
+
expect(resolveDeepLink("")).toEqual({ path: null, params: {} });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles garbage input without throwing", () => {
|
|
53
|
+
expect(() => resolveDeepLink("not a url")).not.toThrow();
|
|
54
|
+
const result = resolveDeepLink("not a url");
|
|
55
|
+
expect(result.path).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("normalizes trailing slashes", () => {
|
|
59
|
+
const result = resolveDeepLink("vexpo://app/linked/");
|
|
60
|
+
expect(result.path).toBe("/linked");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drops nullish query values", () => {
|
|
64
|
+
const result = resolveDeepLink("vexpo://app/linked?x=1");
|
|
65
|
+
expect(result.params).toEqual({ x: "1" });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.expo
|
|
2
|
+
.git
|
|
3
|
+
dist
|
|
4
|
+
node_modules
|
|
5
|
+
ios/Pods
|
|
6
|
+
ios/.xcode.env.local
|
|
7
|
+
__tests__
|
|
8
|
+
*.test.ts
|
|
9
|
+
*.test.tsx
|
|
10
|
+
*.spec.ts
|
|
11
|
+
*.spec.tsx
|
|
12
|
+
.env.example
|
|
13
|
+
.env.convex.local
|
|
14
|
+
README.md
|
|
15
|
+
ios/
|
|
16
|
+
plans/
|
|
17
|
+
docs/
|
|
18
|
+
.agents/
|
|
19
|
+
.claude/
|
|
20
|
+
app-store/
|
|
21
|
+
.husky/
|
|
22
|
+
convex/*.test.ts
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# .env.local template. Copy to .env.local then run `bun run setup`.
|
|
2
|
+
# `bun run setup` writes most of these for you. The two identity vars
|
|
3
|
+
# (EXPO_PUBLIC_APP_BUNDLE_ID and EXPO_PUBLIC_APPLE_TEAM_ID) are prompted by
|
|
4
|
+
# `bun run setup:convex`. Edit by hand if you prefer.
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Convex deployment (written by `bun run setup:convex`)
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
CONVEX_DEPLOYMENT=
|
|
10
|
+
EXPO_PUBLIC_CONVEX_URL=
|
|
11
|
+
EXPO_PUBLIC_CONVEX_SITE_URL=
|
|
12
|
+
EXPO_PUBLIC_SITE_URL=
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# iOS identity (prompted by `bun run setup:convex`, also pushed to Convex env)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Reverse-DNS bundle id, e.g. com.you.vexpo.
|
|
18
|
+
EXPO_PUBLIC_APP_BUNDLE_ID=
|
|
19
|
+
# 10-character Apple Team id from developer.apple.com/account.
|
|
20
|
+
EXPO_PUBLIC_APPLE_TEAM_ID=
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Optional
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Sets `owner` in app.config.ts for EAS. Leave blank if you're not yet linked.
|
|
26
|
+
# EXPO_PUBLIC_EXPO_OWNER=
|
|
27
|
+
# Toggles `name` to "Vexpo (Dev)" so dev and prod can install side-by-side.
|
|
28
|
+
# APP_VARIANT=development
|
|
29
|
+
# Pre-fills the full-access Resend key for `bun run setup:resend` (else prompts).
|
|
30
|
+
# RESEND_FULL_ACCESS_KEY=
|
|
31
|
+
# Skips prompts in `bun run setup:apple` / `setup:asc`.
|
|
32
|
+
# APPLE_P8_PATH=
|
|
33
|
+
# APPLE_AUTH_KEY_PATH=
|
|
34
|
+
# APPLE_ASC_ISSUER_ID=
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Environment files (no native impact)
|
|
2
|
+
.env*
|
|
3
|
+
|
|
4
|
+
# EAS workflows (CI config, not native code)
|
|
5
|
+
.eas/**/*
|
|
6
|
+
|
|
7
|
+
# Convex backend (server-side only)
|
|
8
|
+
convex/**/*
|
|
9
|
+
|
|
10
|
+
# Static assets (handled by expo-asset plugin separately)
|
|
11
|
+
assets/**/*
|
|
12
|
+
|
|
13
|
+
# Documentation
|
|
14
|
+
*.md
|
|
15
|
+
|
|
16
|
+
# Meta / ignored by native
|
|
17
|
+
plans/
|
|
18
|
+
docs/
|
|
19
|
+
.agents/
|
|
20
|
+
.claude/
|
|
21
|
+
.github/
|
|
22
|
+
app-store/
|
|
23
|
+
store.config.json
|
|
24
|
+
eas.json
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Environment variables
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
# Expo
|
|
10
|
+
.expo/
|
|
11
|
+
dist/
|
|
12
|
+
expo-env.d.ts
|
|
13
|
+
|
|
14
|
+
# Native project (CNG)
|
|
15
|
+
ios/
|
|
16
|
+
*.p8
|
|
17
|
+
*.p12
|
|
18
|
+
*.mobileprovision
|
|
19
|
+
*.cer
|
|
20
|
+
|
|
21
|
+
# Apple keys with no extension (Apple's default download naming)
|
|
22
|
+
AuthKey_*
|
|
23
|
+
SubscriptionKey_*
|
|
24
|
+
|
|
25
|
+
# EAS (CLI state; keep workflows tracked)
|
|
26
|
+
.eas/*
|
|
27
|
+
!.eas/workflows/
|
|
28
|
+
|
|
29
|
+
# Agents / local AI tooling
|
|
30
|
+
.claude/
|
|
31
|
+
.agents/
|
|
32
|
+
.cursor/
|
|
33
|
+
.aider*
|
|
34
|
+
skills-lock.json
|
|
35
|
+
|
|
36
|
+
# Working notes (not committed)
|
|
37
|
+
plans/
|
|
38
|
+
|
|
39
|
+
# App Store metadata. Ships with placeholder values so `eas submit` works
|
|
40
|
+
# out-of-the-box. `vexpo rebrand` fills in your real review contact + demo creds.
|
|
41
|
+
# Decide whether to commit your filled-in version based on team policy (it
|
|
42
|
+
# contains demo passwords for App Review).
|
|
43
|
+
# store.config.json
|
|
44
|
+
|
|
45
|
+
# Setup state cache (resumable orchestrator; never holds secrets)
|
|
46
|
+
.setup-state.json
|
|
47
|
+
.setup-state.json.*.tmp
|
|
48
|
+
|
|
49
|
+
# Manual-mode instruction files (contain raw secret values when CLIs missing)
|
|
50
|
+
.vexpo-manual-setup/
|
|
51
|
+
.rebrand-backup/
|
|
52
|
+
|
|
53
|
+
# macOS
|
|
54
|
+
.DS_Store
|
|
55
|
+
|
|
56
|
+
# Pack artifacts. Allow patches/ tarballs since those are the source of truth
|
|
57
|
+
# for `file:` deps the template ships with.
|
|
58
|
+
*.tgz
|
|
59
|
+
!patches/*.tgz
|
|
60
|
+
|
|
61
|
+
# Bun
|
|
62
|
+
bun-error.*
|
|
63
|
+
|
|
64
|
+
# Test coverage
|
|
65
|
+
coverage/
|
|
66
|
+
.vitest-cache/
|
|
67
|
+
|
|
68
|
+
# Build / typecheck artifacts
|
|
69
|
+
tsconfig.tsbuildinfo
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
+
"plugins": ["typescript", "react", "react-hooks", "import", "unicorn", "oxc"],
|
|
4
|
+
"categories": {
|
|
5
|
+
"correctness": "error",
|
|
6
|
+
"suspicious": "warn",
|
|
7
|
+
"perf": "warn",
|
|
8
|
+
"style": "off"
|
|
9
|
+
},
|
|
10
|
+
"rules": {
|
|
11
|
+
"no-console": "off",
|
|
12
|
+
"react/react-in-jsx-scope": "off",
|
|
13
|
+
"react/style-prop-object": "off",
|
|
14
|
+
"react/no-array-index-key": "off",
|
|
15
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
16
|
+
"react-hooks/rules-of-hooks": "error",
|
|
17
|
+
"typescript/no-explicit-any": "warn",
|
|
18
|
+
"typescript/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
|
19
|
+
"import/no-unassigned-import": "off",
|
|
20
|
+
"no-await-in-loop": "off",
|
|
21
|
+
"unicorn/consistent-function-scoping": "off",
|
|
22
|
+
"no-underscore-dangle": ["warn", { "allow": ["_id", "_creationTime"] }],
|
|
23
|
+
"no-shadow": ["warn", { "allow": ["resolve", "reject"] }]
|
|
24
|
+
},
|
|
25
|
+
"ignorePatterns": [
|
|
26
|
+
"node_modules",
|
|
27
|
+
"ios",
|
|
28
|
+
"android",
|
|
29
|
+
"dist",
|
|
30
|
+
".expo",
|
|
31
|
+
"convex/_generated",
|
|
32
|
+
"coverage"
|
|
33
|
+
]
|
|
34
|
+
}
|