@ramonclaudio/create-vexpo 0.1.0 → 0.1.2
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 +10 -10
- package/dist/index.js +8 -7
- package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
- package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
- package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
- package/dist/templates/default/.eas/workflows/release.yml +3 -7
- package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
- package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
- package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
- package/dist/templates/default/.github/workflows/check.yml +20 -12
- package/dist/templates/default/.maestro/launch.yaml +19 -10
- package/dist/templates/default/AGENTS.md +25 -8
- package/dist/templates/default/DESIGN.md +14 -10
- package/dist/templates/default/README.md +83 -78
- package/dist/templates/default/SETUP.md +159 -152
- package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
- package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
- package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
- package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
- package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
- package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
- package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
- package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
- package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
- package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
- package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
- package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
- package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
- package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
- package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
- package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
- package/dist/templates/default/_env.example +7 -7
- package/dist/templates/default/_gitattributes +1 -1
- package/dist/templates/default/_gitignore +17 -2
- package/dist/templates/default/_npmrc +7 -0
- package/dist/templates/default/_oxlintrc.json +1 -1
- package/dist/templates/default/app-store/accessibility.config.json +20 -0
- package/dist/templates/default/app-store/privacy.config.json +27 -0
- package/dist/templates/default/app.config.ts +105 -33
- package/dist/templates/default/app.json +1 -9
- package/dist/templates/default/convex/_generated/api.d.ts +12 -0
- package/dist/templates/default/convex/admin.ts +0 -13
- package/dist/templates/default/convex/appAttest.ts +467 -0
- package/dist/templates/default/convex/appAttestStore.ts +141 -0
- package/dist/templates/default/convex/apple.ts +53 -0
- package/dist/templates/default/convex/auth.ts +6 -45
- package/dist/templates/default/convex/constants.ts +2 -7
- package/dist/templates/default/convex/crons.ts +12 -5
- package/dist/templates/default/convex/email.ts +4 -24
- package/dist/templates/default/convex/env.ts +0 -4
- package/dist/templates/default/convex/errors.ts +0 -7
- package/dist/templates/default/convex/functions.ts +0 -26
- package/dist/templates/default/convex/http.ts +3 -5
- package/dist/templates/default/convex/log.ts +2 -25
- package/dist/templates/default/convex/pushSender.ts +145 -0
- package/dist/templates/default/convex/pushTokens.ts +110 -13
- package/dist/templates/default/convex/rateLimit.ts +8 -39
- package/dist/templates/default/convex/schema.ts +48 -5
- package/dist/templates/default/convex/tsconfig.json +1 -0
- package/dist/templates/default/convex/users.ts +143 -61
- package/dist/templates/default/convex/validators.ts +1 -38
- package/dist/templates/default/convex/webhook.ts +1 -31
- package/dist/templates/default/convex.json +1 -2
- package/dist/templates/default/metro.config.js +9 -1
- package/dist/templates/default/package.json +67 -70
- package/dist/templates/default/plugins/README.md +5 -1
- package/dist/templates/default/scripts/README.md +9 -9
- package/dist/templates/default/scripts/_run.mjs +3 -20
- package/dist/templates/default/scripts/clean.ts +81 -69
- package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
- package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
- package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
- package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
- package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
- package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
- package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
- package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
- package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
- package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
- package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
- package/dist/templates/default/src/app/+native-intent.tsx +25 -0
- package/dist/templates/default/src/app/+not-found.tsx +43 -0
- package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
- package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
- package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
- package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
- package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
- package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
- package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
- package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
- package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
- package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
- package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
- package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
- package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
- package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
- package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
- package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
- package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
- package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
- package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
- package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
- package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
- package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
- package/dist/templates/default/src/hooks/use-network.ts +34 -0
- package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
- package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
- package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
- package/dist/templates/default/src/lib/appAttest.ts +78 -0
- package/dist/templates/default/src/lib/assets.ts +9 -0
- package/dist/templates/default/src/lib/deep-link.ts +82 -0
- package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
- package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
- package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
- package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
- package/dist/templates/default/src/lib/masks.ts +21 -0
- package/dist/templates/default/src/lib/native-state.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
- package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
- package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
- package/dist/templates/default/src/lib/text-style.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
- package/dist/templates/default/store.config.json +1 -1
- package/dist/templates/default/tsconfig.json +3 -1
- package/dist/templates/default/vitest.config.ts +8 -1
- package/package.json +5 -5
- package/dist/templates/default/app/(app)/_layout.tsx +0 -73
- package/dist/templates/default/app/(app)/debug.tsx +0 -389
- package/dist/templates/default/app/(app)/sessions.tsx +0 -191
- package/dist/templates/default/app/(app)/welcome.tsx +0 -140
- package/dist/templates/default/app/+native-intent.tsx +0 -14
- package/dist/templates/default/app/+not-found.tsx +0 -51
- package/dist/templates/default/bun.lock +0 -1860
- package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
- package/dist/templates/default/components/ui/convex-error.tsx +0 -32
- package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
- package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
- package/dist/templates/default/components/ui/status-text.tsx +0 -49
- package/dist/templates/default/components/ui/update-banner.tsx +0 -82
- package/dist/templates/default/fingerprint.config.js +0 -9
- package/dist/templates/default/hooks/use-debounce.ts +0 -20
- package/dist/templates/default/hooks/use-deep-link.ts +0 -43
- package/dist/templates/default/hooks/use-network.ts +0 -11
- package/dist/templates/default/lib/assets.ts +0 -17
- package/dist/templates/default/lib/deep-link.ts +0 -71
- package/dist/templates/default/patches/PR-368.patch +0 -91
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
- /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Authenticated convexTest harness.
|
|
4
|
+
*
|
|
5
|
+
* Proves we can drive the authed Convex functions (authMutation/authQuery)
|
|
6
|
+
* end to end under convex-test by seeding a Better Auth session + user and
|
|
7
|
+
* setting a matching identity.
|
|
8
|
+
*
|
|
9
|
+
* How auth resolves (convex/auth.ts -> @convex-dev/better-auth):
|
|
10
|
+
* safeGetAuthUser(ctx):
|
|
11
|
+
* 1. identity = ctx.auth.getUserIdentity() (root ctx)
|
|
12
|
+
* 2. component.adapter.findOne(session) where
|
|
13
|
+
* _id == identity.sessionId AND expiresAt > now (betterAuth db)
|
|
14
|
+
* 3. component.adapter.findOne(user) where
|
|
15
|
+
* _id == identity.subject (betterAuth db)
|
|
16
|
+
* Both findOne calls resolve `_id` via ctx.db.get(value), so the values
|
|
17
|
+
* MUST be the REAL component doc ids we seed. Then auth.ts looks up the app
|
|
18
|
+
* `users` row by index "authId" == authUser._id.
|
|
19
|
+
*
|
|
20
|
+
* So three rows + one identity:
|
|
21
|
+
* - betterAuth `user` (real id -> identity.subject)
|
|
22
|
+
* - betterAuth `session` (real id -> identity.sessionId, expiresAt future)
|
|
23
|
+
* - app `users` (authId == betterAuth user id)
|
|
24
|
+
*/
|
|
25
|
+
import { ConvexError } from "convex/values";
|
|
26
|
+
import { describe, expect, test } from "vitest";
|
|
27
|
+
|
|
28
|
+
import { api } from "@/convex/_generated/api";
|
|
29
|
+
|
|
30
|
+
import { identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
31
|
+
|
|
32
|
+
describe("authenticated convexTest harness", () => {
|
|
33
|
+
test("baseline: an unauthed public query still works", async () => {
|
|
34
|
+
const t = initConvexTest();
|
|
35
|
+
const providers = await t.query(api.auth.getEnabledProviders, {});
|
|
36
|
+
expect(providers).toMatchObject({ apple: expect.any(Boolean) });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("getMe returns the seeded user when authenticated", async () => {
|
|
40
|
+
const t = initConvexTest();
|
|
41
|
+
const { authUserId, sessionId, appUserId, name, email } = await seedAuthedUser(t, {
|
|
42
|
+
name: "Grace Hopper",
|
|
43
|
+
email: "grace@example.com",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
47
|
+
const me = await asUser.query(api.users.getMe, {});
|
|
48
|
+
|
|
49
|
+
expect(me).not.toBeNull();
|
|
50
|
+
expect(me!._id).toBe(appUserId);
|
|
51
|
+
expect(me!.authUserId).toBe(authUserId);
|
|
52
|
+
expect(me!.name).toBe(name); // merged from better-auth user record
|
|
53
|
+
expect(me!.email).toBe(email);
|
|
54
|
+
expect(me!.emailVerified).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("getMe returns null when unauthenticated", async () => {
|
|
58
|
+
const t = initConvexTest();
|
|
59
|
+
await seedAuthedUser(t); // data exists, but we call without identity
|
|
60
|
+
const me = await t.query(api.users.getMe, {});
|
|
61
|
+
expect(me).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("updateProfile writes bio to the real app users row", async () => {
|
|
65
|
+
const t = initConvexTest();
|
|
66
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
67
|
+
|
|
68
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
69
|
+
const returnedId = await asUser.mutation(api.users.updateProfile, {
|
|
70
|
+
bio: "Countess of computing.",
|
|
71
|
+
});
|
|
72
|
+
expect(returnedId).toBe(appUserId);
|
|
73
|
+
|
|
74
|
+
// Assert the REAL DB effect, read straight from the users table.
|
|
75
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
76
|
+
expect(row?.bio).toBe("Countess of computing.");
|
|
77
|
+
|
|
78
|
+
// And it surfaces through the authed read path too.
|
|
79
|
+
const me = await asUser.query(api.users.getMe, {});
|
|
80
|
+
expect(me!.bio).toBe("Countess of computing.");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("authMutation throws ConvexError when unauthenticated", async () => {
|
|
84
|
+
const t = initConvexTest();
|
|
85
|
+
await expect(t.mutation(api.users.updateProfile, { bio: "nope" })).rejects.toThrowError(
|
|
86
|
+
ConvexError,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// The next two prove the harness genuinely traverses the Better Auth
|
|
91
|
+
// session lookup (expiresAt > now, _id == sessionId) rather than short
|
|
92
|
+
// circuiting on identity.subject alone.
|
|
93
|
+
|
|
94
|
+
test("expired session resolves to no user (getMe null)", async () => {
|
|
95
|
+
const t = initConvexTest();
|
|
96
|
+
const { authUserId, sessionId } = await seedAuthedUser(t, {
|
|
97
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
101
|
+
expect(await asUser.query(api.users.getMe, {})).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("identity with a bogus sessionId resolves to no user", async () => {
|
|
105
|
+
const t = initConvexTest();
|
|
106
|
+
const { authUserId } = await seedAuthedUser(t);
|
|
107
|
+
// Real user/session exist, but the identity points at a session id that
|
|
108
|
+
// isn't in the table. safeGetAuthUser's session findOne returns null.
|
|
109
|
+
const asUser = t.withIdentity(identityFor(authUserId, "nonexistent_session_id"));
|
|
110
|
+
expect(await asUser.query(api.users.getMe, {})).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Shared convexTest harness for the authed Convex functions.
|
|
4
|
+
*
|
|
5
|
+
* Auth chain: authMutation -> requireAuthenticatedUser -> safeGetAuthUser, which
|
|
6
|
+
* reads the @convex-dev/better-auth component (a `session` by _id whose
|
|
7
|
+
* `expiresAt` is in the future, then a `user` by _id == identity.subject) and
|
|
8
|
+
* the mirrored app `users` row (by `authId`). So an authenticated call needs a
|
|
9
|
+
* component user + unexpired session (we capture their REAL ids), a `users` row
|
|
10
|
+
* keyed by that authId, and an identity whose `subject`/`sessionId` match.
|
|
11
|
+
*
|
|
12
|
+
* convex-test exposes `runInComponent` (to seed component tables) at runtime but
|
|
13
|
+
* not in its public types; `AuthedTest` narrows it back so callers stay typed.
|
|
14
|
+
*/
|
|
15
|
+
import { register as registerBetterAuth } from "@convex-dev/better-auth/test";
|
|
16
|
+
import { register as registerRateLimiter } from "@convex-dev/rate-limiter/test";
|
|
17
|
+
import { convexTest } from "convex-test";
|
|
18
|
+
|
|
19
|
+
import type { Id } from "@/convex/_generated/dataModel";
|
|
20
|
+
import schema from "@/convex/schema";
|
|
21
|
+
|
|
22
|
+
const rootModules = import.meta.glob("../../convex/**/*.ts");
|
|
23
|
+
|
|
24
|
+
type SeedCtx = { db: { insert: (table: string, doc: Record<string, unknown>) => Promise<string> } };
|
|
25
|
+
|
|
26
|
+
// Derive the schema-typed TestConvex from a real convexTest(schema) call, so
|
|
27
|
+
// `t.run` ctx.db is typed to the app schema. `ReturnType<typeof convexTest>`
|
|
28
|
+
// alone falls back to the empty default schema.
|
|
29
|
+
function baseConvexTest() {
|
|
30
|
+
return convexTest(schema, rootModules);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AuthedTest = ReturnType<typeof baseConvexTest> & {
|
|
34
|
+
runInComponent: <T>(component: string, fn: (ctx: SeedCtx) => Promise<T>) => Promise<T>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** convexTest with the components the authed functions cross (better-auth, rate-limiter). */
|
|
38
|
+
export function initConvexTest(): AuthedTest {
|
|
39
|
+
const t = baseConvexTest();
|
|
40
|
+
registerBetterAuth(t);
|
|
41
|
+
registerRateLimiter(t);
|
|
42
|
+
return t as AuthedTest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
let seq = 0;
|
|
47
|
+
|
|
48
|
+
export type SeededUser = {
|
|
49
|
+
authUserId: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
appUserId: Id<"users">;
|
|
52
|
+
name: string;
|
|
53
|
+
email: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Seed a Better Auth user + unexpired session and the mirrored app `users` row.
|
|
58
|
+
* Pass `deletedAt` to tombstone the app row, `expiresAt` (in the past) to test
|
|
59
|
+
* an expired session, or `name`/`email` to assert specific identity fields.
|
|
60
|
+
*/
|
|
61
|
+
export async function seedAuthedUser(
|
|
62
|
+
t: AuthedTest,
|
|
63
|
+
overrides: { deletedAt?: number; name?: string; email?: string; expiresAt?: number } = {},
|
|
64
|
+
): Promise<SeededUser> {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const name = overrides.name ?? "Ada Lovelace";
|
|
67
|
+
const email = overrides.email ?? `user${++seq}@example.com`;
|
|
68
|
+
|
|
69
|
+
const { authUserId, sessionId } = await t.runInComponent("betterAuth", async (ctx) => {
|
|
70
|
+
const userId = await ctx.db.insert("user", {
|
|
71
|
+
name,
|
|
72
|
+
email,
|
|
73
|
+
emailVerified: true,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
});
|
|
77
|
+
const session = await ctx.db.insert("session", {
|
|
78
|
+
userId,
|
|
79
|
+
token: `tok_${userId}`,
|
|
80
|
+
expiresAt: overrides.expiresAt ?? now + SEVEN_DAYS,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
});
|
|
84
|
+
return { authUserId: userId, sessionId: session };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const appUserId = await t.run(async (ctx) =>
|
|
88
|
+
ctx.db.insert("users", {
|
|
89
|
+
authId: authUserId,
|
|
90
|
+
createdAt: now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
deletedAt: overrides.deletedAt,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return { authUserId, sessionId, appUserId, name, email };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Identity for `t.withIdentity(...)` matching a seeded user's component ids. */
|
|
100
|
+
export function identityFor(authUserId: string, sessionId: string) {
|
|
101
|
+
return {
|
|
102
|
+
subject: authUserId,
|
|
103
|
+
sessionId,
|
|
104
|
+
issuer: "https://convex.test",
|
|
105
|
+
tokenIdentifier: `https://convex.test|${authUserId}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Account-deletion audit rows for one app user, oldest first. */
|
|
110
|
+
export async function auditRowsFor(t: AuthedTest, userId: Id<"users">) {
|
|
111
|
+
return t.run(async (ctx) =>
|
|
112
|
+
ctx.db
|
|
113
|
+
.query("accountDeletionAudit")
|
|
114
|
+
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
115
|
+
.collect(),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Better Auth component `session` rows for one auth user. `runInComponent`
|
|
121
|
+
* exposes a real ctx but its public type only narrows `db.insert`, so cast to
|
|
122
|
+
* read; filtering in JS avoids depending on the component's index names.
|
|
123
|
+
*/
|
|
124
|
+
export async function componentSessionsFor(t: AuthedTest, authUserId: string) {
|
|
125
|
+
return t.runInComponent("betterAuth", async (ctx) => {
|
|
126
|
+
const db = ctx.db as unknown as {
|
|
127
|
+
query: (table: string) => { collect: () => Promise<Array<{ userId: string }>> };
|
|
128
|
+
};
|
|
129
|
+
const all = await db.query("session").collect();
|
|
130
|
+
return all.filter((s) => s.userId === authUserId);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, sign as nodeSign, type KeyObject } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { encode as cborEncode } from "cbor-x";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { verifyAssertionBytes, verifyAttestationBytes } from "@/convex/appAttest";
|
|
7
|
+
|
|
8
|
+
describe("verifyAttestationBytes", () => {
|
|
9
|
+
test("rejects non-Apple fmt", () => {
|
|
10
|
+
const bogus = cborEncode({ fmt: "fido-u2f", attStmt: {}, authData: Buffer.alloc(0) });
|
|
11
|
+
expect(() =>
|
|
12
|
+
verifyAttestationBytes({
|
|
13
|
+
keyId: "ignored",
|
|
14
|
+
attestation: bogus,
|
|
15
|
+
challenge: "c",
|
|
16
|
+
bundleId: "com.example",
|
|
17
|
+
teamId: "AAAAA12345",
|
|
18
|
+
environment: "development",
|
|
19
|
+
}),
|
|
20
|
+
).toThrow(/unexpected fmt/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rejects missing x5c", () => {
|
|
24
|
+
const bogus = cborEncode({ fmt: "apple-appattest", attStmt: {}, authData: Buffer.alloc(0) });
|
|
25
|
+
expect(() =>
|
|
26
|
+
verifyAttestationBytes({
|
|
27
|
+
keyId: "ignored",
|
|
28
|
+
attestation: bogus,
|
|
29
|
+
challenge: "c",
|
|
30
|
+
bundleId: "com.example",
|
|
31
|
+
teamId: "AAAAA12345",
|
|
32
|
+
environment: "development",
|
|
33
|
+
}),
|
|
34
|
+
).toThrow(/missing x5c or authData/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects single-cert chain", () => {
|
|
38
|
+
const bogus = cborEncode({
|
|
39
|
+
fmt: "apple-appattest",
|
|
40
|
+
attStmt: { x5c: [Buffer.alloc(8)] },
|
|
41
|
+
authData: Buffer.alloc(64),
|
|
42
|
+
});
|
|
43
|
+
expect(() =>
|
|
44
|
+
verifyAttestationBytes({
|
|
45
|
+
keyId: "ignored",
|
|
46
|
+
attestation: bogus,
|
|
47
|
+
challenge: "c",
|
|
48
|
+
bundleId: "com.example",
|
|
49
|
+
teamId: "AAAAA12345",
|
|
50
|
+
environment: "development",
|
|
51
|
+
}),
|
|
52
|
+
).toThrow(/missing x5c or authData/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("verifyAssertionBytes", () => {
|
|
57
|
+
test("rejects missing signature", () => {
|
|
58
|
+
const bogus = cborEncode({ authenticatorData: Buffer.alloc(37) });
|
|
59
|
+
expect(() =>
|
|
60
|
+
verifyAssertionBytes({
|
|
61
|
+
assertion: bogus,
|
|
62
|
+
payload: "p",
|
|
63
|
+
bundleId: "com.example",
|
|
64
|
+
teamId: "AAAAA12345",
|
|
65
|
+
publicKey: Buffer.alloc(0),
|
|
66
|
+
storedCounter: 0,
|
|
67
|
+
}),
|
|
68
|
+
).toThrow(/missing signature/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("rejects missing authenticatorData", () => {
|
|
72
|
+
const bogus = cborEncode({ signature: Buffer.alloc(64) });
|
|
73
|
+
expect(() =>
|
|
74
|
+
verifyAssertionBytes({
|
|
75
|
+
assertion: bogus,
|
|
76
|
+
payload: "p",
|
|
77
|
+
bundleId: "com.example",
|
|
78
|
+
teamId: "AAAAA12345",
|
|
79
|
+
publicKey: Buffer.alloc(0),
|
|
80
|
+
storedCounter: 0,
|
|
81
|
+
}),
|
|
82
|
+
).toThrow(/missing signature or authenticatorData/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The real verification core (ECDSA signature, counter monotonicity, rpIdHash)
|
|
87
|
+
// was previously unreached: every test above only hit the CBOR early-guards. A
|
|
88
|
+
// genuine signed round-trip exercises it, so a regression that inverts the
|
|
89
|
+
// counter check or short-circuits the signature verify can't slip through.
|
|
90
|
+
const sha256 = (b: Buffer) => createHash("sha256").update(b).digest();
|
|
91
|
+
|
|
92
|
+
function makeAuthData(teamId: string, bundleId: string, counter: number): Buffer {
|
|
93
|
+
const rpIdHash = sha256(Buffer.from(`${teamId}.${bundleId}`, "utf8"));
|
|
94
|
+
const flags = Buffer.from([0x00]);
|
|
95
|
+
const counterBuf = Buffer.alloc(4);
|
|
96
|
+
counterBuf.writeUInt32BE(counter);
|
|
97
|
+
return Buffer.concat([rpIdHash, flags, counterBuf]); // 37 bytes, no credential block
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Mirror the device: sign over sha256(authData || sha256(payload)) with the
|
|
101
|
+
// digest passed directly (algorithm null), matching the server's verify(null, …).
|
|
102
|
+
function signAssertion(privateKey: KeyObject, authData: Buffer, payload: string): Buffer {
|
|
103
|
+
const hashedData = sha256(Buffer.concat([authData, sha256(Buffer.from(payload, "utf8"))]));
|
|
104
|
+
const signature = nodeSign(null, hashedData, privateKey);
|
|
105
|
+
return cborEncode({ signature, authenticatorData: authData });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("verifyAssertionBytes (signature + counter + rpIdHash)", () => {
|
|
109
|
+
const TEAM_ID = "AAAAA12345";
|
|
110
|
+
const BUNDLE_ID = "com.example.app";
|
|
111
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
112
|
+
const publicKeyDer = publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
113
|
+
|
|
114
|
+
test("accepts a valid assertion and returns the new counter", () => {
|
|
115
|
+
const authData = makeAuthData(TEAM_ID, BUNDLE_ID, 5);
|
|
116
|
+
const assertion = signAssertion(privateKey, authData, "request-payload");
|
|
117
|
+
const counter = verifyAssertionBytes({
|
|
118
|
+
assertion,
|
|
119
|
+
payload: "request-payload",
|
|
120
|
+
bundleId: BUNDLE_ID,
|
|
121
|
+
teamId: TEAM_ID,
|
|
122
|
+
publicKey: publicKeyDer,
|
|
123
|
+
storedCounter: 0,
|
|
124
|
+
});
|
|
125
|
+
expect(counter).toBe(5);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("rejects when the payload differs from what was signed", () => {
|
|
129
|
+
const authData = makeAuthData(TEAM_ID, BUNDLE_ID, 5);
|
|
130
|
+
const assertion = signAssertion(privateKey, authData, "original-payload");
|
|
131
|
+
expect(() =>
|
|
132
|
+
verifyAssertionBytes({
|
|
133
|
+
assertion,
|
|
134
|
+
payload: "tampered-payload",
|
|
135
|
+
bundleId: BUNDLE_ID,
|
|
136
|
+
teamId: TEAM_ID,
|
|
137
|
+
publicKey: publicKeyDer,
|
|
138
|
+
storedCounter: 0,
|
|
139
|
+
}),
|
|
140
|
+
).toThrow(/signature failed verification/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("rejects a replayed counter (not strictly greater than stored)", () => {
|
|
144
|
+
const authData = makeAuthData(TEAM_ID, BUNDLE_ID, 3);
|
|
145
|
+
const assertion = signAssertion(privateKey, authData, "p");
|
|
146
|
+
expect(() =>
|
|
147
|
+
verifyAssertionBytes({
|
|
148
|
+
assertion,
|
|
149
|
+
payload: "p",
|
|
150
|
+
bundleId: BUNDLE_ID,
|
|
151
|
+
teamId: TEAM_ID,
|
|
152
|
+
publicKey: publicKeyDer,
|
|
153
|
+
storedCounter: 5,
|
|
154
|
+
}),
|
|
155
|
+
).toThrow(/not strictly greater/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("rejects an rpIdHash that doesn't match TEAM_ID.BUNDLE_ID", () => {
|
|
159
|
+
const authData = makeAuthData("WRONG00000", "com.wrong.app", 5);
|
|
160
|
+
const assertion = signAssertion(privateKey, authData, "p");
|
|
161
|
+
expect(() =>
|
|
162
|
+
verifyAssertionBytes({
|
|
163
|
+
assertion,
|
|
164
|
+
payload: "p",
|
|
165
|
+
bundleId: BUNDLE_ID,
|
|
166
|
+
teamId: TEAM_ID,
|
|
167
|
+
publicKey: publicKeyDer,
|
|
168
|
+
storedCounter: 0,
|
|
169
|
+
}),
|
|
170
|
+
).toThrow(/rpIdHash mismatch/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* convexTest coverage for `appAttestStore.consumeChallenge`, the App Attest
|
|
4
|
+
* replay guard. It returns true only the first time a known, unexpired nonce is
|
|
5
|
+
* seen, marking it consumed atomically so a captured nonce can't be replayed.
|
|
6
|
+
* Drop the `row.used` check and a replayed nonce verifies forever.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { internal } from "@/convex/_generated/api";
|
|
11
|
+
|
|
12
|
+
import { initConvexTest } from "./_harness";
|
|
13
|
+
|
|
14
|
+
describe("appAttestStore.consumeChallenge", () => {
|
|
15
|
+
test("single-use: first consume succeeds, second fails", async () => {
|
|
16
|
+
const t = initConvexTest();
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
await t.mutation(internal.appAttestStore.createChallenge, {
|
|
19
|
+
nonce: "n1",
|
|
20
|
+
expiresAt: now + 60_000,
|
|
21
|
+
});
|
|
22
|
+
expect(await t.mutation(internal.appAttestStore.consumeChallenge, { nonce: "n1", now })).toBe(
|
|
23
|
+
true,
|
|
24
|
+
);
|
|
25
|
+
expect(await t.mutation(internal.appAttestStore.consumeChallenge, { nonce: "n1", now })).toBe(
|
|
26
|
+
false,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("expired challenge is rejected", async () => {
|
|
31
|
+
const t = initConvexTest();
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
await t.mutation(internal.appAttestStore.createChallenge, { nonce: "n2", expiresAt: now - 1 });
|
|
34
|
+
expect(await t.mutation(internal.appAttestStore.consumeChallenge, { nonce: "n2", now })).toBe(
|
|
35
|
+
false,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("unknown nonce is rejected", async () => {
|
|
40
|
+
const t = initConvexTest();
|
|
41
|
+
expect(
|
|
42
|
+
await t.mutation(internal.appAttestStore.consumeChallenge, {
|
|
43
|
+
nonce: "never-issued",
|
|
44
|
+
now: Date.now(),
|
|
45
|
+
}),
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for the authed `pushTokens.remove` mutation.
|
|
4
|
+
*
|
|
5
|
+
* `remove` (convex/pushTokens.ts) is an authMutation that:
|
|
6
|
+
* 1. rate-limits on "userAction" (needs the rateLimiter component),
|
|
7
|
+
* 2. finds the row by the "by_token" index,
|
|
8
|
+
* 3. deletes it ONLY when it exists AND row.userId === ctx.user._id,
|
|
9
|
+
* 4. returns null.
|
|
10
|
+
*
|
|
11
|
+
* Auth is seeded the same way as _auth-harness.test.ts: a Better Auth
|
|
12
|
+
* component user + unexpired session whose real ids back the identity, plus
|
|
13
|
+
* the mirrored app `users` row keyed by authId. pushTokens.userId is the app
|
|
14
|
+
* users _id, so we seed rows pointing at it.
|
|
15
|
+
*/
|
|
16
|
+
import { ConvexError } from "convex/values";
|
|
17
|
+
import { describe, expect, test } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { api } from "@/convex/_generated/api";
|
|
20
|
+
import type { Id } from "@/convex/_generated/dataModel";
|
|
21
|
+
|
|
22
|
+
import { type AuthedTest, identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
23
|
+
|
|
24
|
+
/** Insert a pushTokens row owned by `userId` and return its id. */
|
|
25
|
+
async function seedPushToken(t: AuthedTest, userId: Id<"users">, token: string) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
return t.run(async (ctx) =>
|
|
28
|
+
ctx.db.insert("pushTokens", {
|
|
29
|
+
userId,
|
|
30
|
+
token,
|
|
31
|
+
deviceType: "ios" as const,
|
|
32
|
+
createdAt: now,
|
|
33
|
+
updatedAt: now,
|
|
34
|
+
lastSeenAt: now,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("pushTokens.remove (authMutation)", () => {
|
|
40
|
+
test("deletes the caller's own token and returns null", async () => {
|
|
41
|
+
const t = initConvexTest();
|
|
42
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
43
|
+
const tokenId = await seedPushToken(t, appUserId, "ExponentPushToken[own-device]");
|
|
44
|
+
|
|
45
|
+
// Sanity: the row exists before we call remove.
|
|
46
|
+
expect(await t.run((ctx) => ctx.db.get(tokenId))).not.toBeNull();
|
|
47
|
+
|
|
48
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
49
|
+
const result = await asUser.mutation(api.pushTokens.remove, {
|
|
50
|
+
token: "ExponentPushToken[own-device]",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).toBeNull();
|
|
54
|
+
// Real DB effect: the row is gone.
|
|
55
|
+
expect(await t.run((ctx) => ctx.db.get(tokenId))).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("does NOT delete a token owned by another user", async () => {
|
|
59
|
+
const t = initConvexTest();
|
|
60
|
+
const caller = await seedAuthedUser(t);
|
|
61
|
+
const other = await seedAuthedUser(t);
|
|
62
|
+
|
|
63
|
+
// A token that belongs to `other`, but `caller` knows the string and asks
|
|
64
|
+
// to remove it. The userId guard must leave it untouched.
|
|
65
|
+
const sharedToken = "ExponentPushToken[other-device]";
|
|
66
|
+
const otherTokenId = await seedPushToken(t, other.appUserId, sharedToken);
|
|
67
|
+
|
|
68
|
+
const asCaller = t.withIdentity(identityFor(caller.authUserId, caller.sessionId));
|
|
69
|
+
const result = await asCaller.mutation(api.pushTokens.remove, { token: sharedToken });
|
|
70
|
+
|
|
71
|
+
expect(result).toBeNull(); // no error, just a no-op
|
|
72
|
+
// The other user's row survives.
|
|
73
|
+
const surviving = await t.run((ctx) => ctx.db.get(otherTokenId));
|
|
74
|
+
expect(surviving).not.toBeNull();
|
|
75
|
+
expect(surviving?.userId).toBe(other.appUserId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("removing a non-existent token is a no-op returning null", async () => {
|
|
79
|
+
const t = initConvexTest();
|
|
80
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
81
|
+
// Seed an unrelated token to prove remove() doesn't nuke the table.
|
|
82
|
+
const keepId = await seedPushToken(t, appUserId, "ExponentPushToken[keep]");
|
|
83
|
+
|
|
84
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
85
|
+
const result = await asUser.mutation(api.pushTokens.remove, {
|
|
86
|
+
token: "ExponentPushToken[never-registered]",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result).toBeNull();
|
|
90
|
+
expect(await t.run((ctx) => ctx.db.get(keepId))).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("throws ConvexError when called unauthenticated", async () => {
|
|
94
|
+
const t = initConvexTest();
|
|
95
|
+
const { appUserId } = await seedAuthedUser(t);
|
|
96
|
+
const tokenId = await seedPushToken(t, appUserId, "ExponentPushToken[guarded]");
|
|
97
|
+
|
|
98
|
+
// No identity -> requireAuthenticatedUser throws before any delete.
|
|
99
|
+
await expect(
|
|
100
|
+
t.mutation(api.pushTokens.remove, { token: "ExponentPushToken[guarded]" }),
|
|
101
|
+
).rejects.toThrowError(ConvexError);
|
|
102
|
+
|
|
103
|
+
// And nothing was deleted.
|
|
104
|
+
expect(await t.run((ctx) => ctx.db.get(tokenId))).not.toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|