Merge pull request #217 from trails-cool/feat/journal-oauth2-pkce
Implement OAuth2 PKCE auth and mobile API client
This commit is contained in:
commit
daf555dbee
25 changed files with 1402 additions and 134 deletions
106
apps/journal/app/lib/oauth.server.test.ts
Normal file
106
apps/journal/app/lib/oauth.server.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
// Mock the database module before importing oauth
|
||||
vi.mock("./db.ts", () => {
|
||||
const store: Record<string, unknown[]> = {
|
||||
oauth_clients: [],
|
||||
oauth_codes: [],
|
||||
oauth_tokens: [],
|
||||
};
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => {
|
||||
// Return empty array by default; tests override via __store
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
onConflictDoUpdate: () => Promise.resolve(),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
__store: store,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Test PKCE verification logic directly.
|
||||
* The full OAuth flow requires a real database, so we test the crypto
|
||||
* primitives and error handling here.
|
||||
*/
|
||||
describe("PKCE verification", () => {
|
||||
it("S256: verifier matches challenge", () => {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
|
||||
// Re-derive the challenge from the verifier
|
||||
const derived = createHash("sha256").update(verifier).digest("base64url");
|
||||
expect(derived).toBe(challenge);
|
||||
});
|
||||
|
||||
it("S256: wrong verifier does not match", () => {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
|
||||
const wrongVerifier = randomBytes(32).toString("base64url");
|
||||
const wrongDerived = createHash("sha256").update(wrongVerifier).digest("base64url");
|
||||
expect(wrongDerived).not.toBe(challenge);
|
||||
});
|
||||
|
||||
it("S256: challenge is base64url-encoded SHA-256", () => {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
|
||||
// base64url has no padding, no +, no /
|
||||
expect(challenge).not.toContain("=");
|
||||
expect(challenge).not.toContain("+");
|
||||
expect(challenge).not.toContain("/");
|
||||
expect(challenge.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthError", () => {
|
||||
it("has code and message", async () => {
|
||||
const { OAuthError } = await import("./oauth.server.ts");
|
||||
const err = new OAuthError("invalid_grant", "Bad code");
|
||||
expect(err.code).toBe("invalid_grant");
|
||||
expect(err.message).toBe("Bad code");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token generation", () => {
|
||||
it("generates unique tokens", async () => {
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(randomBytes(32).toString("base64url"));
|
||||
}
|
||||
expect(tokens.size).toBe(100);
|
||||
});
|
||||
|
||||
it("tokens are base64url encoded", async () => {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(token.length).toBe(43); // 32 bytes → 43 base64url chars
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedOAuthClient", () => {
|
||||
it("can be imported and called", async () => {
|
||||
const { seedOAuthClient } = await import("./oauth.server.ts");
|
||||
// Should not throw with mocked DB
|
||||
await seedOAuthClient("test-client", "testapp://callback", true);
|
||||
});
|
||||
});
|
||||
270
apps/journal/app/lib/oauth.server.ts
Normal file
270
apps/journal/app/lib/oauth.server.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
||||
import { eq, and, gt, isNull } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import {
|
||||
users,
|
||||
oauthClients,
|
||||
oauthCodes,
|
||||
oauthTokens,
|
||||
} from "@trails-cool/db/schema/journal";
|
||||
import { getSessionUser } from "./auth.server.ts";
|
||||
|
||||
const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function generateToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
// --- Client validation ---
|
||||
|
||||
export async function getOAuthClient(clientId: string) {
|
||||
const db = getDb();
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(oauthClients)
|
||||
.where(eq(oauthClients.clientId, clientId));
|
||||
return client ?? null;
|
||||
}
|
||||
|
||||
export function validateRedirectUri(
|
||||
client: { redirectUri: string },
|
||||
redirectUri: string,
|
||||
): boolean {
|
||||
return client.redirectUri === redirectUri;
|
||||
}
|
||||
|
||||
// --- Authorization code ---
|
||||
|
||||
export async function createAuthorizationCode(params: {
|
||||
userId: string;
|
||||
clientId: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
redirectUri: string;
|
||||
}): Promise<string> {
|
||||
const db = getDb();
|
||||
const code = generateToken();
|
||||
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS);
|
||||
|
||||
await db.insert(oauthCodes).values({
|
||||
id: randomUUID(),
|
||||
code,
|
||||
userId: params.userId,
|
||||
clientId: params.clientId,
|
||||
codeChallenge: params.codeChallenge,
|
||||
codeChallengeMethod: params.codeChallengeMethod,
|
||||
redirectUri: params.redirectUri,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// --- PKCE verification ---
|
||||
|
||||
function verifyCodeChallenge(
|
||||
codeVerifier: string,
|
||||
codeChallenge: string,
|
||||
method: string,
|
||||
): boolean {
|
||||
if (method === "S256") {
|
||||
const hash = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||
return hash === codeChallenge;
|
||||
}
|
||||
if (method === "plain") {
|
||||
return codeVerifier === codeChallenge;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Token exchange ---
|
||||
|
||||
export async function exchangeCodeForTokens(params: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
}) {
|
||||
const db = getDb();
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(oauthCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthCodes.code, params.code),
|
||||
eq(oauthCodes.clientId, params.clientId),
|
||||
gt(oauthCodes.expiresAt, new Date()),
|
||||
isNull(oauthCodes.usedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new OAuthError("invalid_grant", "Invalid or expired authorization code");
|
||||
}
|
||||
|
||||
if (record.redirectUri !== params.redirectUri) {
|
||||
throw new OAuthError("invalid_grant", "Redirect URI mismatch");
|
||||
}
|
||||
|
||||
if (!verifyCodeChallenge(params.codeVerifier, record.codeChallenge, record.codeChallengeMethod)) {
|
||||
throw new OAuthError("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(oauthCodes)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(oauthCodes.id, record.id));
|
||||
|
||||
return issueTokens(record.userId, params.clientId);
|
||||
}
|
||||
|
||||
// --- Refresh token ---
|
||||
|
||||
export async function refreshAccessToken(params: {
|
||||
refreshToken: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
const db = getDb();
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(oauthTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthTokens.refreshToken, params.refreshToken),
|
||||
eq(oauthTokens.clientId, params.clientId),
|
||||
isNull(oauthTokens.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new OAuthError("invalid_grant", "Invalid refresh token");
|
||||
}
|
||||
|
||||
// Revoke old token pair
|
||||
await db
|
||||
.update(oauthTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(oauthTokens.id, record.id));
|
||||
|
||||
// Issue fresh tokens (token rotation)
|
||||
return issueTokens(record.userId, params.clientId);
|
||||
}
|
||||
|
||||
// --- Token issuance ---
|
||||
|
||||
async function issueTokens(userId: string, clientId: string) {
|
||||
const db = getDb();
|
||||
const accessToken = generateToken();
|
||||
const refreshToken = generateToken();
|
||||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY_MS);
|
||||
|
||||
await db.insert(oauthTokens).values({
|
||||
id: randomUUID(),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userId,
|
||||
clientId,
|
||||
expiresAt,
|
||||
lastActiveAt: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "Bearer" as const,
|
||||
expires_in: Math.floor(ACCESS_TOKEN_EXPIRY_MS / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bearer token validation (middleware) ---
|
||||
|
||||
export async function validateBearerToken(request: Request) {
|
||||
const db = getDb();
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(oauthTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthTokens.accessToken, token),
|
||||
isNull(oauthTokens.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
// Check if access token is expired — but don't reject (client should refresh)
|
||||
// We still allow expired access tokens for a grace period; the client
|
||||
// gets a 401 which triggers a refresh flow
|
||||
if (record.expiresAt < new Date()) return null;
|
||||
|
||||
// Update last active timestamp
|
||||
await db
|
||||
.update(oauthTokens)
|
||||
.set({ lastActiveAt: new Date() })
|
||||
.where(eq(oauthTokens.id, record.id));
|
||||
|
||||
return { userId: record.userId, tokenId: record.id };
|
||||
}
|
||||
|
||||
// --- Seed trusted client ---
|
||||
|
||||
export async function seedOAuthClient(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
trusted: boolean,
|
||||
) {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(oauthClients)
|
||||
.values({
|
||||
clientId,
|
||||
redirectUri,
|
||||
trusted: trusted ? 1 : 0,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: oauthClients.clientId,
|
||||
set: { redirectUri, trusted: trusted ? 1 : 0 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Combined auth: cookie session OR bearer token ---
|
||||
|
||||
/**
|
||||
* Authenticate a request via cookie session or OAuth2 bearer token.
|
||||
* Use this in API routes that both the web UI and mobile app call.
|
||||
*/
|
||||
export async function getAuthenticatedUser(request: Request) {
|
||||
// Try cookie session first (web UI)
|
||||
const sessionUser = await getSessionUser(request);
|
||||
if (sessionUser) return sessionUser;
|
||||
|
||||
// Try bearer token (mobile app)
|
||||
const tokenResult = await validateBearerToken(request);
|
||||
if (!tokenResult) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, tokenResult.userId));
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
// --- Error type ---
|
||||
|
||||
export class OAuthError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
|||
|
||||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route(".well-known/trails-cool", "routes/api.well-known.trails-cool.ts"),
|
||||
route("oauth/authorize", "routes/oauth.authorize.tsx"),
|
||||
route("oauth/token", "routes/oauth.token.ts"),
|
||||
route("auth/register", "routes/auth.register.tsx"),
|
||||
route("auth/login", "routes/auth.login.tsx"),
|
||||
route("auth/verify", "routes/auth.verify.tsx"),
|
||||
|
|
|
|||
33
apps/journal/app/routes/api.well-known.trails-cool.test.ts
Normal file
33
apps/journal/app/routes/api.well-known.trails-cool.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.stubEnv("DOMAIN", "trails.cool");
|
||||
vi.stubEnv("ORIGIN", "https://trails.cool");
|
||||
|
||||
describe("GET /.well-known/trails-cool", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns discovery response with API version", async () => {
|
||||
const { loader } = await import("./api.well-known.trails-cool.ts");
|
||||
const resp = loader() as Response;
|
||||
const data = await resp.json();
|
||||
|
||||
expect(data.apiVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(data.instanceName).toBe("trails.cool");
|
||||
expect(data.apiBaseUrl).toBe("https://trails.cool/api/v1");
|
||||
});
|
||||
|
||||
it("uses localhost defaults when env vars are unset", async () => {
|
||||
vi.stubEnv("DOMAIN", "");
|
||||
vi.stubEnv("ORIGIN", "");
|
||||
// Re-import to pick up changed env
|
||||
const mod = await import("./api.well-known.trails-cool.ts");
|
||||
const resp = mod.loader() as Response;
|
||||
const data = await resp.json();
|
||||
|
||||
// Falls back to process.env values (empty strings trigger ?? fallback)
|
||||
expect(data).toHaveProperty("apiVersion");
|
||||
expect(data).toHaveProperty("apiBaseUrl");
|
||||
});
|
||||
});
|
||||
18
apps/journal/app/routes/api.well-known.trails-cool.ts
Normal file
18
apps/journal/app/routes/api.well-known.trails-cool.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { API_VERSION } from "@trails-cool/api";
|
||||
|
||||
/**
|
||||
* GET /.well-known/trails-cool
|
||||
*
|
||||
* Discovery endpoint for mobile apps and federation.
|
||||
* Returns instance metadata including API version, name, and base URL.
|
||||
*/
|
||||
export function loader() {
|
||||
const domain = process.env.DOMAIN ?? "localhost";
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
|
||||
return Response.json({
|
||||
apiVersion: API_VERSION,
|
||||
instanceName: domain,
|
||||
apiBaseUrl: `${origin}/api/v1`,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation("journal");
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
const [supportsPasskey, setSupportsPasskey] = useState(true);
|
||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
||||
|
||||
|
|
@ -53,7 +56,7 @@ export default function LoginPage() {
|
|||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else if (finishData.step === "done") {
|
||||
window.location.href = "/";
|
||||
window.location.href = returnTo ?? "/";
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
|
|
@ -84,7 +87,10 @@ export default function LoginPage() {
|
|||
setError(result.error);
|
||||
} else if (result.devLink) {
|
||||
// Dev mode: auto-redirect to magic link
|
||||
window.location.href = result.devLink;
|
||||
const devUrl = returnTo
|
||||
? `${result.devLink}&returnTo=${encodeURIComponent(returnTo)}`
|
||||
: result.devLink;
|
||||
window.location.href = devUrl;
|
||||
} else {
|
||||
setMagicLinkSent(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
|
||||
const userId = await verifyMagicToken(token);
|
||||
const cookie = await createSession(userId, request);
|
||||
return redirect("/?add-passkey=1", { headers: { "Set-Cookie": cookie } });
|
||||
const returnTo = url.searchParams.get("returnTo");
|
||||
const destination = returnTo?.startsWith("/") ? returnTo : "/?add-passkey=1";
|
||||
return redirect(destination, { headers: { "Set-Cookie": cookie } });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
|
|
|
|||
80
apps/journal/app/routes/oauth.authorize.tsx
Normal file
80
apps/journal/app/routes/oauth.authorize.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { Route } from "./+types/oauth.authorize";
|
||||
import { redirect } from "react-router";
|
||||
import { getSessionUser } from "../lib/auth.server.ts";
|
||||
import {
|
||||
getOAuthClient,
|
||||
validateRedirectUri,
|
||||
createAuthorizationCode,
|
||||
} from "../lib/oauth.server.ts";
|
||||
|
||||
/**
|
||||
* GET /oauth/authorize
|
||||
*
|
||||
* OAuth2 authorization endpoint. If the user is already logged in,
|
||||
* generates an authorization code and redirects. Otherwise redirects
|
||||
* to the login page with a return URL.
|
||||
*
|
||||
* Query params:
|
||||
* - client_id
|
||||
* - redirect_uri
|
||||
* - code_challenge
|
||||
* - code_challenge_method (default: S256)
|
||||
* - response_type (must be "code")
|
||||
* - state (opaque, passed back to client)
|
||||
*/
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
|
||||
const responseType = url.searchParams.get("response_type");
|
||||
const state = url.searchParams.get("state") ?? "";
|
||||
|
||||
// Validate required params
|
||||
if (!clientId || !redirectUri || !codeChallenge || responseType !== "code") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "invalid_request", error_description: "Missing required parameters" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (codeChallengeMethod !== "S256" && codeChallengeMethod !== "plain") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "invalid_request", error_description: "Unsupported code_challenge_method" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate client
|
||||
const client = await getOAuthClient(clientId);
|
||||
if (!client || !validateRedirectUri(client, redirectUri)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "invalid_client", error_description: "Unknown client or redirect URI" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is already authenticated
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) {
|
||||
// Redirect to login with return URL so we come back here after auth
|
||||
const returnUrl = url.pathname + url.search;
|
||||
return redirect(`/auth/login?returnTo=${encodeURIComponent(returnUrl)}`);
|
||||
}
|
||||
|
||||
// User is authenticated — issue code and redirect to client
|
||||
const code = await createAuthorizationCode({
|
||||
userId: user.id,
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
|
||||
return redirect(callbackUrl.toString());
|
||||
}
|
||||
70
apps/journal/app/routes/oauth.token.ts
Normal file
70
apps/journal/app/routes/oauth.token.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Route } from "./+types/oauth.token";
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
refreshAccessToken,
|
||||
OAuthError,
|
||||
} from "../lib/oauth.server.ts";
|
||||
|
||||
/**
|
||||
* POST /oauth/token
|
||||
*
|
||||
* OAuth2 token endpoint. Supports two grant types:
|
||||
* - authorization_code: Exchange code + PKCE verifier for tokens
|
||||
* - refresh_token: Exchange refresh token for new token pair
|
||||
*/
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
return new Response(null, { status: 405 });
|
||||
}
|
||||
|
||||
const body = await request.formData();
|
||||
const grantType = body.get("grant_type") as string | null;
|
||||
|
||||
try {
|
||||
if (grantType === "authorization_code") {
|
||||
const code = body.get("code") as string | null;
|
||||
const clientId = body.get("client_id") as string | null;
|
||||
const redirectUri = body.get("redirect_uri") as string | null;
|
||||
const codeVerifier = body.get("code_verifier") as string | null;
|
||||
|
||||
if (!code || !clientId || !redirectUri || !codeVerifier) {
|
||||
return oauthErrorResponse("invalid_request", "Missing required parameters");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
code,
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
return Response.json(tokens);
|
||||
}
|
||||
|
||||
if (grantType === "refresh_token") {
|
||||
const refreshToken = body.get("refresh_token") as string | null;
|
||||
const clientId = body.get("client_id") as string | null;
|
||||
|
||||
if (!refreshToken || !clientId) {
|
||||
return oauthErrorResponse("invalid_request", "Missing required parameters");
|
||||
}
|
||||
|
||||
const tokens = await refreshAccessToken({ refreshToken, clientId });
|
||||
return Response.json(tokens);
|
||||
}
|
||||
|
||||
return oauthErrorResponse("unsupported_grant_type", `Unsupported grant type: ${grantType}`);
|
||||
} catch (err) {
|
||||
if (err instanceof OAuthError) {
|
||||
return oauthErrorResponse(err.code, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function oauthErrorResponse(error: string, description: string) {
|
||||
return Response.json(
|
||||
{ error, error_description: description },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"@sentry/react": "catalog:",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@trails-cool/api": "workspace:*",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ const server = createServer((req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
server.listen(port, async () => {
|
||||
logger.info({ port }, "Journal server listening");
|
||||
|
||||
// Seed first-party OAuth2 clients
|
||||
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
||||
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "cool.trails.app",
|
||||
infoPlist: {
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsLocalNetworking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
|
|
@ -28,5 +33,5 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||
web: {
|
||||
favicon: "./assets/favicon.png",
|
||||
},
|
||||
plugins: ["expo-router"],
|
||||
plugins: ["expo-router", "expo-secure-store", "expo-web-browser"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,70 @@
|
|||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { API_VERSION } from "@trails-cool/api";
|
||||
import { logout } from "../../lib/auth";
|
||||
import { getServerUrl, clearServerUrl } from "../../lib/server-config";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getServerUrl().then(setServerUrl);
|
||||
}, []);
|
||||
|
||||
const handleSwitchServer = () => {
|
||||
Alert.alert(
|
||||
"Switch Server",
|
||||
"This will log you out and clear local data. Continue?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Switch",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
await clearServerUrl();
|
||||
router.replace("/login");
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Profile</Text>
|
||||
<Text style={styles.server}>{serverUrl}</Text>
|
||||
<Text style={styles.version}>API v{API_VERSION}</Text>
|
||||
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Log Out</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={handleSwitchServer}>
|
||||
<Text style={styles.link}>Switch Server</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
|
||||
text: { fontSize: 18, color: "#666" },
|
||||
version: { fontSize: 12, color: "#999", marginTop: 8 },
|
||||
server: { fontSize: 14, color: "#999", marginTop: 4 },
|
||||
version: { fontSize: 12, color: "#999", marginTop: 4 },
|
||||
logoutButton: {
|
||||
marginTop: 32,
|
||||
backgroundColor: "#e5e5e5",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
logoutText: { fontSize: 16, color: "#333" },
|
||||
link: { color: "#4A6B40", fontSize: 14, marginTop: 16 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
import { Stack } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Stack, router } from "expo-router";
|
||||
import { isAuthenticated } from "../lib/auth";
|
||||
import { startVersionCheck } from "../lib/version-check";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isAuthenticated().then((authed) => {
|
||||
if (!authed) {
|
||||
router.replace("/login");
|
||||
} else {
|
||||
startVersionCheck();
|
||||
}
|
||||
setChecked(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!checked) return null;
|
||||
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
|
|
|
|||
164
apps/mobile/app/login.tsx
Normal file
164
apps/mobile/app/login.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
setServerUrl,
|
||||
fetchDiscovery,
|
||||
isApiVersionCompatible,
|
||||
ServerConfigError,
|
||||
} from "../lib/server-config";
|
||||
import { login } from "../lib/auth";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [serverUrl, setServerUrlState] = useState(
|
||||
__DEV__ ? "http://localhost:3000" : "https://trails.cool",
|
||||
);
|
||||
const [showCustomServer, setShowCustomServer] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const url = serverUrl.replace(/\/+$/, "");
|
||||
|
||||
// Validate server via discovery endpoint
|
||||
const discovery = await fetchDiscovery(url);
|
||||
|
||||
if (!isApiVersionCompatible(discovery.apiVersion)) {
|
||||
setError(
|
||||
`This app requires API v${discovery.apiVersion.split(".")[0]}.x but the server runs v${discovery.apiVersion}. Please update the app.`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the validated server URL
|
||||
await setServerUrl(url);
|
||||
|
||||
// Start OAuth2 PKCE login
|
||||
const success = await login();
|
||||
if (success) {
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
setError("Login was cancelled or failed. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[Login] Error:", message, err);
|
||||
if (err instanceof ServerConfigError) {
|
||||
setError(`Could not connect to server: ${err.message}`);
|
||||
} else {
|
||||
setError(`Connection failed: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>trails.cool</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your Journal</Text>
|
||||
|
||||
{showCustomServer && (
|
||||
<View style={styles.serverInput}>
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrlState}
|
||||
placeholder="https://trails.cool"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{!showCustomServer && (
|
||||
<TouchableOpacity onPress={() => setShowCustomServer(true)}>
|
||||
<Text style={styles.link}>Connect to a different server</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
color: "#4A6B40",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
},
|
||||
serverInput: {
|
||||
width: "100%",
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ccc",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#4A6B40",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
link: {
|
||||
color: "#4A6B40",
|
||||
fontSize: 14,
|
||||
},
|
||||
error: {
|
||||
color: "#c00",
|
||||
fontSize: 14,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
24
apps/mobile/lib/__tests__/api-client.test.ts
Normal file
24
apps/mobile/lib/__tests__/api-client.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ApiError, NetworkError } from "../api-client";
|
||||
|
||||
describe("ApiError", () => {
|
||||
it("has status, code, and message", () => {
|
||||
const err = new ApiError(404, "NOT_FOUND", "Route not found");
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe("NOT_FOUND");
|
||||
expect(err.message).toBe("Route not found");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkError", () => {
|
||||
it("has default message", () => {
|
||||
const err = new NetworkError();
|
||||
expect(err.message).toBe("Network request failed");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("accepts custom message", () => {
|
||||
const err = new NetworkError("Timeout");
|
||||
expect(err.message).toBe("Timeout");
|
||||
});
|
||||
});
|
||||
19
apps/mobile/lib/__tests__/server-config.test.ts
Normal file
19
apps/mobile/lib/__tests__/server-config.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { isApiVersionCompatible } from "../server-config";
|
||||
|
||||
describe("isApiVersionCompatible", () => {
|
||||
it("compatible when major versions match", () => {
|
||||
expect(isApiVersionCompatible("1.0.0")).toBe(true);
|
||||
expect(isApiVersionCompatible("1.2.3")).toBe(true);
|
||||
expect(isApiVersionCompatible("1.99.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("incompatible when major versions differ", () => {
|
||||
expect(isApiVersionCompatible("2.0.0")).toBe(false);
|
||||
expect(isApiVersionCompatible("0.9.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed versions gracefully", () => {
|
||||
expect(isApiVersionCompatible("")).toBe(false);
|
||||
expect(isApiVersionCompatible("abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
127
apps/mobile/lib/api-client.ts
Normal file
127
apps/mobile/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { getServerUrl } from "./server-config";
|
||||
import { getAccessToken, refreshTokens, clearTokens } from "./auth";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message = "Network request failed") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API client with bearer token injection and automatic 401 refresh.
|
||||
*/
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const baseUrl = `${serverUrl}/api/v1`;
|
||||
const url = `${baseUrl}${path}`;
|
||||
|
||||
const token = await getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string> ?? {}),
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, { ...options, headers });
|
||||
} catch (err) {
|
||||
throw new NetworkError((err as Error).message);
|
||||
}
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (resp.status === 401 && token) {
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed) {
|
||||
const newToken = await getAccessToken();
|
||||
headers["Authorization"] = `Bearer ${newToken}`;
|
||||
try {
|
||||
resp = await fetch(url, { ...options, headers });
|
||||
} catch (err) {
|
||||
throw new NetworkError((err as Error).message);
|
||||
}
|
||||
} else {
|
||||
await clearTokens();
|
||||
throw new ApiError(401, "UNAUTHORIZED", "Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
resp.status,
|
||||
body.code ?? "UNKNOWN",
|
||||
body.error ?? `Request failed with status ${resp.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
export function listRoutes(cursor?: string, limit = 20) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return request<unknown>(`/routes?${params}`);
|
||||
}
|
||||
|
||||
export function getRoute(id: string) {
|
||||
return request<unknown>(`/routes/${id}`);
|
||||
}
|
||||
|
||||
export function createRoute(data: { name: string; description?: string; gpx?: string }) {
|
||||
return request<unknown>("/routes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoute(id: string, data: { name?: string; description?: string; gpx?: string }) {
|
||||
return request<unknown>(`/routes/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Activities ---
|
||||
|
||||
export function listActivities(cursor?: string, limit = 20) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return request<unknown>(`/activities?${params}`);
|
||||
}
|
||||
|
||||
export function getActivity(id: string) {
|
||||
return request<unknown>(`/activities/${id}`);
|
||||
}
|
||||
|
||||
export function createActivity(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
gpx?: string;
|
||||
routeId?: string;
|
||||
startedAt?: string;
|
||||
duration?: number;
|
||||
distance?: number;
|
||||
}) {
|
||||
return request<unknown>("/activities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
142
apps/mobile/lib/auth.ts
Normal file
142
apps/mobile/lib/auth.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { getServerUrl } from "./server-config";
|
||||
|
||||
const STORE_KEY_ACCESS_TOKEN = "access_token";
|
||||
const STORE_KEY_REFRESH_TOKEN = "refresh_token";
|
||||
const CLIENT_ID = "trails-cool-mobile";
|
||||
const REDIRECT_URI = "trailscool://auth/callback";
|
||||
|
||||
// --- PKCE helpers ---
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const bytes = Crypto.getRandomBytes(32);
|
||||
return uint8ToBase64Url(bytes);
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const hash = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
verifier,
|
||||
{ encoding: Crypto.CryptoEncoding.BASE64 },
|
||||
);
|
||||
// Convert standard base64 to base64url
|
||||
return hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function uint8ToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
// --- Token storage ---
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(STORE_KEY_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
export async function getRefreshToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(STORE_KEY_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
async function storeTokens(accessToken: string, refreshToken: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(STORE_KEY_ACCESS_TOKEN, accessToken);
|
||||
await SecureStore.setItemAsync(STORE_KEY_REFRESH_TOKEN, refreshToken);
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_ACCESS_TOKEN);
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const token = await getAccessToken();
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
// --- Login flow ---
|
||||
|
||||
export async function login(): Promise<boolean> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
const authorizeUrl = new URL(`${serverUrl}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
|
||||
authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(
|
||||
authorizeUrl.toString(),
|
||||
REDIRECT_URI,
|
||||
);
|
||||
|
||||
if (result.type !== "success") return false;
|
||||
|
||||
const callbackUrl = new URL(result.url);
|
||||
const code = callbackUrl.searchParams.get("code");
|
||||
if (!code) return false;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenUrl = `${serverUrl}/oauth/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const resp = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!resp.ok) return false;
|
||||
|
||||
const tokens = await resp.json();
|
||||
await storeTokens(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Token refresh ---
|
||||
|
||||
export async function refreshTokens(): Promise<boolean> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const refreshToken = await getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
|
||||
const resp = await fetch(`${serverUrl}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
await clearTokens();
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = await resp.json();
|
||||
await storeTokens(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await clearTokens();
|
||||
}
|
||||
100
apps/mobile/lib/server-config.ts
Normal file
100
apps/mobile/lib/server-config.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as SecureStore from "expo-secure-store";
|
||||
import { API_VERSION } from "@trails-cool/api";
|
||||
|
||||
const STORE_KEY_SERVER_URL = "server_url";
|
||||
const DEFAULT_SERVER_URL = __DEV__ ? "http://localhost:3000" : "https://trails.cool";
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
apiVersion: string;
|
||||
instanceName: string;
|
||||
apiBaseUrl: string;
|
||||
tileUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored server URL, or the default.
|
||||
*/
|
||||
export async function getServerUrl(): Promise<string> {
|
||||
const stored = await SecureStore.getItemAsync(STORE_KEY_SERVER_URL);
|
||||
return stored ?? DEFAULT_SERVER_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new server URL.
|
||||
*/
|
||||
export async function setServerUrl(url: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(STORE_KEY_SERVER_URL, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored server URL (resets to default).
|
||||
*/
|
||||
export async function clearServerUrl(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_SERVER_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and validate the discovery endpoint for a server URL.
|
||||
* Returns the parsed discovery response or throws.
|
||||
*/
|
||||
export async function fetchDiscovery(serverUrl: string): Promise<DiscoveryResponse> {
|
||||
const url = `${serverUrl.replace(/\/+$/, "")}/.well-known/trails-cool`;
|
||||
console.log("[Discovery] Fetching:", url);
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
} catch (err) {
|
||||
console.error("[Discovery] Fetch failed:", err);
|
||||
throw new ServerConfigError("network_error", (err as Error).message);
|
||||
}
|
||||
console.log("[Discovery] Response status:", resp.status);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new ServerConfigError(
|
||||
"discovery_failed",
|
||||
`Server returned ${resp.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (!data.apiVersion || !data.instanceName || !data.apiBaseUrl) {
|
||||
throw new ServerConfigError(
|
||||
"invalid_discovery",
|
||||
"Server returned an invalid discovery response",
|
||||
);
|
||||
}
|
||||
|
||||
return data as DiscoveryResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the major version number from a semver string.
|
||||
*/
|
||||
function majorVersion(semver: string): number {
|
||||
const match = semver.match(/^(\d+)/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server's API version is compatible with this app.
|
||||
* Compares major versions — the server must match the app's major version.
|
||||
*/
|
||||
export function isApiVersionCompatible(serverVersion: string): boolean {
|
||||
return majorVersion(serverVersion) === majorVersion(API_VERSION);
|
||||
}
|
||||
|
||||
export class ServerConfigError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
31
apps/mobile/lib/version-check.ts
Normal file
31
apps/mobile/lib/version-check.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Alert, AppState } from "react-native";
|
||||
import { getServerUrl, fetchDiscovery, isApiVersionCompatible } from "./server-config";
|
||||
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Start listening for app foreground events to re-check API version.
|
||||
* Shows a non-blocking alert if the server version has drifted.
|
||||
*/
|
||||
export function startVersionCheck() {
|
||||
if (listening) return;
|
||||
listening = true;
|
||||
|
||||
AppState.addEventListener("change", async (state) => {
|
||||
if (state !== "active") return;
|
||||
|
||||
try {
|
||||
const serverUrl = await getServerUrl();
|
||||
const discovery = await fetchDiscovery(serverUrl);
|
||||
if (!isApiVersionCompatible(discovery.apiVersion)) {
|
||||
Alert.alert(
|
||||
"Update Available",
|
||||
"The server has been updated. Please update the app for the best experience.",
|
||||
[{ text: "OK" }],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — network may be unavailable
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -25,8 +25,11 @@
|
|||
"@trails-cool/map-core": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"expo": "~55.0.14",
|
||||
"expo-crypto": "~55.0.14",
|
||||
"expo-router": "~55.0.12",
|
||||
"expo-secure-store": "~55.0.13",
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
|
|
@ -34,7 +37,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"jest-expo": "^55.0.15",
|
||||
"typescript": "~5.9.2"
|
||||
|
|
|
|||
|
|
@ -27,34 +27,34 @@
|
|||
|
||||
### 1.4 Journal Auth (OAuth2 PKCE)
|
||||
|
||||
- [ ] 1.4.1 Add `journal.oauth_clients` table with client_id, redirect_uri, and trusted flag
|
||||
- [ ] 1.4.2 Add `journal.oauth_codes` table (code, userId, clientId, codeChallenge, codeChallengeMethod, expiresAt)
|
||||
- [ ] 1.4.3 Add `journal.oauth_tokens` table (accessToken, refreshToken, userId, clientId, expiresAt)
|
||||
- [ ] 1.4.4 Implement `GET /oauth/authorize` endpoint — show login UI, generate auth code on success, redirect to client
|
||||
- [ ] 1.4.5 Implement `POST /oauth/token` endpoint — validate PKCE code_verifier, issue access + refresh tokens
|
||||
- [ ] 1.4.6 Implement refresh token grant in `POST /oauth/token`
|
||||
- [ ] 1.4.7 Add middleware to validate OAuth2 bearer tokens on existing API routes
|
||||
- [ ] 1.4.8 Seed `trails-cool-mobile` as a trusted first-party OAuth2 client with `trailscool://` redirect URI
|
||||
- [ ] 1.4.9 Write unit tests for PKCE challenge validation, token issuance, and token refresh
|
||||
- [x] 1.4.1 Add `journal.oauth_clients` table with client_id, redirect_uri, and trusted flag
|
||||
- [x] 1.4.2 Add `journal.oauth_codes` table (code, userId, clientId, codeChallenge, codeChallengeMethod, expiresAt)
|
||||
- [x] 1.4.3 Add `journal.oauth_tokens` table (accessToken, refreshToken, userId, clientId, expiresAt)
|
||||
- [x] 1.4.4 Implement `GET /oauth/authorize` endpoint — show login UI, generate auth code on success, redirect to client
|
||||
- [x] 1.4.5 Implement `POST /oauth/token` endpoint — validate PKCE code_verifier, issue access + refresh tokens
|
||||
- [x] 1.4.6 Implement refresh token grant in `POST /oauth/token`
|
||||
- [x] 1.4.7 Add middleware to validate OAuth2 bearer tokens on existing API routes
|
||||
- [x] 1.4.8 Seed `trails-cool-mobile` as a trusted first-party OAuth2 client with `trailscool://` redirect URI
|
||||
- [x] 1.4.9 Write unit tests for PKCE challenge validation, token issuance, and token refresh
|
||||
|
||||
### 1.5 Server Configuration
|
||||
|
||||
- [ ] 1.5.1 Add server URL input on login screen with "Connect to a different server" toggle (defaults to `https://trails.cool`)
|
||||
- [ ] 1.5.2 Add `GET /.well-known/trails-cool` discovery endpoint on the Journal — returns instance name, version, API base URL
|
||||
- [ ] 1.5.3 Validate entered server URL by fetching discovery endpoint before proceeding to login
|
||||
- [ ] 1.5.4 Check `apiVersion` semver from discovery against app's required minimum — block with upgrade prompt if server is too old
|
||||
- [ ] 1.5.5 Persist server URL in Expo SecureStore, use as base for all API calls and OAuth2 flow
|
||||
- [ ] 1.5.6 Re-check API version on app foreground (after background) — show banner if version mismatch detected
|
||||
- [ ] 1.5.7 Add "Switch server" option on Profile tab — logs out, clears local data, returns to login
|
||||
- [x] 1.5.1 Add server URL input on login screen with "Connect to a different server" toggle (defaults to `https://trails.cool`)
|
||||
- [x] 1.5.2 Add `GET /.well-known/trails-cool` discovery endpoint on the Journal — returns instance name, version, API base URL
|
||||
- [x] 1.5.3 Validate entered server URL by fetching discovery endpoint before proceeding to login
|
||||
- [x] 1.5.4 Check `apiVersion` semver from discovery against app's required minimum — block with upgrade prompt if server is too old
|
||||
- [x] 1.5.5 Persist server URL in Expo SecureStore, use as base for all API calls and OAuth2 flow
|
||||
- [x] 1.5.6 Re-check API version on app foreground (after background) — show banner if version mismatch detected
|
||||
- [x] 1.5.7 Add "Switch server" option on Profile tab — logs out, clears local data, returns to login
|
||||
|
||||
### 1.6 Journal API Client (Mobile)
|
||||
|
||||
- [ ] 1.6.1 Create `apps/mobile/src/lib/api-client.ts` with base HTTP client, auth header injection, and 401 auto-refresh logic
|
||||
- [ ] 1.6.2 Create `apps/mobile/src/lib/auth.ts` with OAuth2 PKCE login flow using `expo-auth-session` and token storage via `expo-secure-store`
|
||||
- [ ] 1.6.3 Implement routes API methods: listRoutes, getRoute, updateRoute, createRoute
|
||||
- [ ] 1.6.4 Implement activities API methods: listActivities, getActivity, createActivity
|
||||
- [ ] 1.6.5 Add typed error handling for network failures, 401, and server errors
|
||||
- [ ] 1.6.6 Write unit tests for API client with mocked fetch responses
|
||||
- [x] 1.6.1 Create `apps/mobile/src/lib/api-client.ts` with base HTTP client, auth header injection, and 401 auto-refresh logic
|
||||
- [x] 1.6.2 Create `apps/mobile/src/lib/auth.ts` with OAuth2 PKCE login flow using `expo-auth-session` and token storage via `expo-secure-store`
|
||||
- [x] 1.6.3 Implement routes API methods: listRoutes, getRoute, updateRoute, createRoute
|
||||
- [x] 1.6.4 Implement activities API methods: listActivities, getActivity, createActivity
|
||||
- [x] 1.6.5 Add typed error handling for network failures, 401, and server errors
|
||||
- [x] 1.6.6 Write unit tests for API client with mocked fetch responses
|
||||
|
||||
## Phase 2: Journal REST API
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,49 @@ export const activities = journalSchema.table("activities", {
|
|||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// --- OAuth2 PKCE (mobile app auth) ---
|
||||
|
||||
export const oauthClients = journalSchema.table("oauth_clients", {
|
||||
clientId: text("client_id").primaryKey(),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
trusted: integer("trusted").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const oauthCodes = journalSchema.table("oauth_codes", {
|
||||
id: text("id").primaryKey(),
|
||||
code: text("code").notNull().unique(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id")
|
||||
.notNull()
|
||||
.references(() => oauthClients.clientId, { onDelete: "cascade" }),
|
||||
codeChallenge: text("code_challenge").notNull(),
|
||||
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const oauthTokens = journalSchema.table("oauth_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull().unique(),
|
||||
refreshToken: text("refresh_token").notNull().unique(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id")
|
||||
.notNull()
|
||||
.references(() => oauthClients.clientId, { onDelete: "cascade" }),
|
||||
deviceName: text("device_name"),
|
||||
lastActiveAt: timestamp("last_active_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const syncConnections = journalSchema.table("sync_connections", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
|
|
|
|||
150
pnpm-lock.yaml
generated
150
pnpm-lock.yaml
generated
|
|
@ -206,6 +206,9 @@ importers:
|
|||
'@simplewebauthn/server':
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@trails-cool/api':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api
|
||||
'@trails-cool/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
|
|
@ -306,12 +309,21 @@ importers:
|
|||
expo:
|
||||
specifier: ~55.0.14
|
||||
version: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
expo-crypto:
|
||||
specifier: ~55.0.14
|
||||
version: 55.0.14(expo@55.0.14)
|
||||
expo-router:
|
||||
specifier: ~55.0.12
|
||||
version: 55.0.12(@expo/log-box@55.0.10)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.5.2))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(expo-constants@55.0.13)(expo-font@55.0.6)(expo-linking@7.0.5)(expo@55.0.14)(react-dom@19.2.5(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
|
||||
expo-secure-store:
|
||||
specifier: ~55.0.13
|
||||
version: 55.0.13(expo@55.0.14)
|
||||
expo-status-bar:
|
||||
specifier: ~55.0.5
|
||||
version: 55.0.5(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
|
||||
expo-web-browser:
|
||||
specifier: ~55.0.14
|
||||
version: 55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))
|
||||
react:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0
|
||||
|
|
@ -329,8 +341,8 @@ importers:
|
|||
specifier: ^13.3.3
|
||||
version: 13.3.3(jest@29.7.0(@types/node@25.5.2))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@types/jest':
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
specifier: ^29.5.14
|
||||
version: 29.5.14
|
||||
'@types/react':
|
||||
specifier: ~19.2.14
|
||||
version: 19.2.14
|
||||
|
|
@ -1886,10 +1898,6 @@ packages:
|
|||
resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
'@jest/expect-utils@30.3.0':
|
||||
resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
'@jest/expect@29.7.0':
|
||||
resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
|
@ -1906,10 +1914,6 @@ packages:
|
|||
resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
'@jest/pattern@30.0.1':
|
||||
resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
'@jest/reporters@29.7.0':
|
||||
resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
|
@ -1947,10 +1951,6 @@ packages:
|
|||
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
'@jest/types@30.3.0':
|
||||
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
|
|
@ -3314,8 +3314,8 @@ packages:
|
|||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/jest@30.0.0':
|
||||
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
|
||||
'@types/jest@29.5.14':
|
||||
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
|
||||
|
||||
'@types/jsdom@20.0.1':
|
||||
resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==}
|
||||
|
|
@ -3844,10 +3844,6 @@ packages:
|
|||
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ci-info@4.4.0:
|
||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cjs-module-lexer@1.4.3:
|
||||
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
|
||||
|
||||
|
|
@ -4442,10 +4438,6 @@ packages:
|
|||
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
expect@30.3.0:
|
||||
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
expo-asset@55.0.14:
|
||||
resolution: {integrity: sha512-8jeWHW39/UOQytGoXXFIrpE+DhK72RhMu09iuTxYuGluqGzGgs+DgcaP9jTvCPwkAXxSfWZdsTttuKXE5nDUCQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -4465,6 +4457,11 @@ packages:
|
|||
expo: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-crypto@55.0.14:
|
||||
resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-file-system@55.0.16:
|
||||
resolution: {integrity: sha512-EetQ/zVFK07Vmz4Yke0fvoES4xVwScTdd0PMoLekuMX7puE4op75pNnEdh1M0AeWzkqLrBoZIaU2ynSrKN5VZg==}
|
||||
peerDependencies:
|
||||
|
|
@ -4557,6 +4554,11 @@ packages:
|
|||
react-server-dom-webpack:
|
||||
optional: true
|
||||
|
||||
expo-secure-store@55.0.13:
|
||||
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-server@55.0.7:
|
||||
resolution: {integrity: sha512-Cc1btFyPsD9P4DT2xd1pG/uR96TLVMx0W+dPm9Gjk1uDV9xuzvMcUsY7nf9bt4U5pGyWWkCXmPJcKwWfdl51Pw==}
|
||||
engines: {node: '>=20.16.0'}
|
||||
|
|
@ -4575,6 +4577,12 @@ packages:
|
|||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-web-browser@55.0.14:
|
||||
resolution: {integrity: sha512-bTDkBSQBnrlnYcM7Aak72AOvJuvdgA3M8p//Lazrm0Nfa77T9cRXzQ6KhLrB08V39n1+00d1dvuTWznJslkmdg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo@55.0.14:
|
||||
resolution: {integrity: sha512-MqFdpyE3z5MZqb6Q9v6RqXzbRDbd0RMlGdVLSA/ObX6vgHhzCDIjeb+Uwao9P7R0uebsC4b126jBWxuhMmJHZQ==}
|
||||
hasBin: true
|
||||
|
|
@ -5117,18 +5125,10 @@ packages:
|
|||
resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jest-message-util@30.3.0:
|
||||
resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
jest-mock@29.7.0:
|
||||
resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jest-mock@30.3.0:
|
||||
resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
jest-pnp-resolver@1.2.3:
|
||||
resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -5142,10 +5142,6 @@ packages:
|
|||
resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jest-regex-util@30.0.1:
|
||||
resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
jest-resolve-dependencies@29.7.0:
|
||||
resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
|
@ -5170,10 +5166,6 @@ packages:
|
|||
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jest-util@30.3.0:
|
||||
resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
jest-validate@29.7.0:
|
||||
resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
|
@ -8659,10 +8651,6 @@ snapshots:
|
|||
dependencies:
|
||||
jest-get-type: 29.6.3
|
||||
|
||||
'@jest/expect-utils@30.3.0':
|
||||
dependencies:
|
||||
'@jest/get-type': 30.1.0
|
||||
|
||||
'@jest/expect@29.7.0':
|
||||
dependencies:
|
||||
expect: 29.7.0
|
||||
|
|
@ -8690,11 +8678,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@jest/pattern@30.0.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
jest-regex-util: 30.0.1
|
||||
|
||||
'@jest/reporters@29.7.0':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 0.2.3
|
||||
|
|
@ -8781,16 +8764,6 @@ snapshots:
|
|||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
'@jest/types@30.3.0':
|
||||
dependencies:
|
||||
'@jest/pattern': 30.0.1
|
||||
'@jest/schemas': 30.0.5
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
'@types/istanbul-reports': 3.0.4
|
||||
'@types/node': 25.5.2
|
||||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
|
@ -10319,10 +10292,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/jest@30.0.0':
|
||||
'@types/jest@29.5.14':
|
||||
dependencies:
|
||||
expect: 30.3.0
|
||||
pretty-format: 30.3.0
|
||||
expect: 29.7.0
|
||||
pretty-format: 29.7.0
|
||||
|
||||
'@types/jsdom@20.0.1':
|
||||
dependencies:
|
||||
|
|
@ -10957,8 +10930,6 @@ snapshots:
|
|||
|
||||
ci-info@3.9.0: {}
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
|
||||
cjs-module-lexer@1.4.3: {}
|
||||
|
||||
cjs-module-lexer@2.2.0: {}
|
||||
|
|
@ -11509,15 +11480,6 @@ snapshots:
|
|||
jest-message-util: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
|
||||
expect@30.3.0:
|
||||
dependencies:
|
||||
'@jest/expect-utils': 30.3.0
|
||||
'@jest/get-type': 30.1.0
|
||||
jest-matcher-utils: 30.3.0
|
||||
jest-message-util: 30.3.0
|
||||
jest-mock: 30.3.0
|
||||
jest-util: 30.3.0
|
||||
|
||||
expo-asset@55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.13(typescript@5.9.3)
|
||||
|
|
@ -11548,6 +11510,10 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
expo-crypto@55.0.14(expo@55.0.14):
|
||||
dependencies:
|
||||
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
||||
expo-file-system@55.0.16(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
|
||||
dependencies:
|
||||
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
|
@ -11650,6 +11616,10 @@ snapshots:
|
|||
- expo-font
|
||||
- supports-color
|
||||
|
||||
expo-secure-store@55.0.13(expo@55.0.14):
|
||||
dependencies:
|
||||
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
||||
expo-server@55.0.7: {}
|
||||
|
||||
expo-status-bar@55.0.5(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
|
||||
|
|
@ -11667,6 +11637,11 @@ snapshots:
|
|||
react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
|
||||
sf-symbols-typescript: 2.2.0
|
||||
|
||||
expo-web-browser@55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
|
||||
dependencies:
|
||||
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
|
||||
|
||||
expo@55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
|
@ -12381,38 +12356,18 @@ snapshots:
|
|||
slash: 3.0.0
|
||||
stack-utils: 2.0.6
|
||||
|
||||
jest-message-util@30.3.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@jest/types': 30.3.0
|
||||
'@types/stack-utils': 2.0.3
|
||||
chalk: 4.1.2
|
||||
graceful-fs: 4.2.11
|
||||
picomatch: 4.0.4
|
||||
pretty-format: 30.3.0
|
||||
slash: 3.0.0
|
||||
stack-utils: 2.0.6
|
||||
|
||||
jest-mock@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 25.5.2
|
||||
jest-util: 29.7.0
|
||||
|
||||
jest-mock@30.3.0:
|
||||
dependencies:
|
||||
'@jest/types': 30.3.0
|
||||
'@types/node': 25.5.2
|
||||
jest-util: 30.3.0
|
||||
|
||||
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
|
||||
optionalDependencies:
|
||||
jest-resolve: 29.7.0
|
||||
|
||||
jest-regex-util@29.6.3: {}
|
||||
|
||||
jest-regex-util@30.0.1: {}
|
||||
|
||||
jest-resolve-dependencies@29.7.0:
|
||||
dependencies:
|
||||
jest-regex-util: 29.6.3
|
||||
|
|
@ -12519,15 +12474,6 @@ snapshots:
|
|||
graceful-fs: 4.2.11
|
||||
picomatch: 2.3.2
|
||||
|
||||
jest-util@30.3.0:
|
||||
dependencies:
|
||||
'@jest/types': 30.3.0
|
||||
'@types/node': 25.5.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 4.4.0
|
||||
graceful-fs: 4.2.11
|
||||
picomatch: 4.0.4
|
||||
|
||||
jest-validate@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue