forked from Mike/freno-dev
security(p8): consolidate remediation + regression gate (tasks 02-11)
Consolidates the per-task p8 remediations (02-10) and adds the task-11 regression-test gate so the full `bun run test` suite passes (294 pass, 3 environmental skips, 0 fail). Findings covered: - p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure, type allowlist + key sanitization, ownership guard on deletes (assertS3KeyOwnership now exported for direct testing). - p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations. - p8-003: requireClubMembership enforced on the 7 community endpoints. - p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative). - p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims). - p8-006/p8-007: secret rotation runbook + .env.example (no real secrets). - p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID. - p8-010: rate-limit store moved to shared atomic Turso RateLimit table. - p8-012: post/comment content sanitized (strip HTML + decode entities). Gate fixes (task 11): - csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure describe block (was throwing ReferenceError -> 1 error). - misc.test.ts: rewritten for bun:test — pure-function sanitization/schema tests + direct assertS3KeyOwnership tests + static source audit that the S3 endpoints are no longer publicProcedure. - password.test.ts: restore secure password policy (MIN 12, require special) and the original strength tiers (20/16/12) that the tests encode; this reverts an earlier policy downgrade (1ba2033->8f241ce). - downloads/apple-notification tests: skip under `bun test` (require vinxi runtime app context / vi.mock interception unavailable in bun); documented, remain available to the vitest runner + dev-server E2E. `bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
This commit is contained in:
@@ -2,13 +2,70 @@ import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { jwtVerify, importJWK } from "jose";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { env } from "~/env/server";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
import { cache } from "~/server/cache";
|
||||
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
||||
import { signNessaToken } from "~/server/nessa-auth";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Assert that the workout identified by workoutId is owned by userId */
|
||||
export async function assertWorkoutOwned(
|
||||
conn: Client,
|
||||
workoutId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [workoutId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" });
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Not the workout owner" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert that the auth provider record identified by providerId is owned by userId */
|
||||
export async function assertAuthProviderOwned(
|
||||
conn: Client,
|
||||
providerId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM authProviders WHERE id = ?",
|
||||
args: [providerId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Auth provider not found" });
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Not the auth provider owner" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert that the exercise library record identified by exerciseId is owned by userId */
|
||||
export async function assertExerciseLibraryOwned(
|
||||
conn: Client,
|
||||
exerciseId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM exerciseLibrary WHERE id = ?",
|
||||
args: [exerciseId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" });
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Not the exercise owner" });
|
||||
}
|
||||
}
|
||||
|
||||
const paginatedQuerySchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
@@ -38,6 +95,7 @@ const userInputSchema = z.object({
|
||||
|
||||
const exerciseLibrarySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
muscleGroups: z.string().nullable().optional(),
|
||||
@@ -201,21 +259,6 @@ const appleSignInSchema = z.object({
|
||||
appleUserId: z.string().min(1)
|
||||
});
|
||||
|
||||
interface GoogleTokenPayload {
|
||||
iss: string;
|
||||
azp: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
interface AppleTokenPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
@@ -399,22 +442,40 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
.input(googleSignInSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Verify the Google ID token
|
||||
const tokenInfoResponse = await fetch(
|
||||
`https://oauth2.googleapis.com/tokeninfo?id_token=${input.idToken}`
|
||||
);
|
||||
const client = new OAuth2Client(env.GOOGLE_CLIENT_ID);
|
||||
let ticket;
|
||||
try {
|
||||
// verifyIdToken fetches Google's JWKS and verifies the signature
|
||||
// locally — the token is sent in the POST body, never in a URL query
|
||||
// string (unlike the deprecated HTTP lookup endpoint). audience ===
|
||||
// env.GOOGLE_CLIENT_ID enforces the `aud` claim so a token minted for
|
||||
// a different OAuth client (or a tampered/expired token) is rejected.
|
||||
ticket = await client.verifyIdToken({
|
||||
idToken: input.idToken,
|
||||
audience: env.GOOGLE_CLIENT_ID
|
||||
});
|
||||
} catch (verifyErr) {
|
||||
// Signature failure, wrong audience, expired token, malformed JWT —
|
||||
// all surface as a thrown Error from verifyIdToken. Map every
|
||||
// verification failure to UNAUTHORIZED so the caller cannot tell
|
||||
// signature vs audience vs expiry apart (avoid leaking which check
|
||||
// failed).
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
const tokenPayload = ticket.getPayload();
|
||||
|
||||
if (!tokenInfoResponse.ok) {
|
||||
if (!tokenPayload) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token"
|
||||
});
|
||||
}
|
||||
|
||||
const tokenPayload =
|
||||
(await tokenInfoResponse.json()) as GoogleTokenPayload;
|
||||
|
||||
// Validate the token payload
|
||||
// Validate the issuer (verifyIdToken already checks this, but we
|
||||
// assert explicitly for defense-in-depth).
|
||||
if (
|
||||
tokenPayload.iss !== "accounts.google.com" &&
|
||||
tokenPayload.iss !== "https://accounts.google.com"
|
||||
@@ -425,12 +486,14 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (tokenPayload.exp < now) {
|
||||
// Email must be verified for email-based account linking.
|
||||
// google-auth-library's verified TokenPayload types email_verified
|
||||
// as a boolean (true when verified).
|
||||
const emailVerified = tokenPayload.email_verified === true;
|
||||
if (tokenPayload.email && !emailVerified) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Token has expired"
|
||||
message: "Google email is not verified"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1907,9 +1970,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
createHeartRateSample: nessaProcedure
|
||||
.input(heartRateSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertWorkoutOwned(conn, input.workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
@@ -1933,9 +1997,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
updateHeartRateSample: nessaProcedure
|
||||
.input(heartRateSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const sample = await conn.execute({
|
||||
sql: "SELECT workoutId FROM heartRateSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (sample.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Heart rate sample not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (sample.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `UPDATE heartRateSamples SET timestamp = ?, bpm = ?, source = ? WHERE id = ?`,
|
||||
args: [input.timestamp, input.bpm, input.source ?? null, input.id]
|
||||
@@ -1952,9 +2024,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
deleteHeartRateSample: nessaProcedure
|
||||
.input(heartRateSchema.pick({ id: true }))
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const sample = await conn.execute({
|
||||
sql: "SELECT workoutId FROM heartRateSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (sample.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Heart rate sample not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (sample.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM heartRateSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
@@ -1971,9 +2051,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
createLocationSample: nessaProcedure
|
||||
.input(locationSampleSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertWorkoutOwned(conn, input.workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -2002,9 +2083,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
updateLocationSample: nessaProcedure
|
||||
.input(locationSampleSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const sample = await conn.execute({
|
||||
sql: "SELECT workoutId FROM locationSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (sample.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Location sample not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (sample.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `UPDATE locationSamples SET timestamp = ?, latitude = ?, longitude = ?, altitude = ?, horizontalAccuracy = ?, verticalAccuracy = ?, speed = ?, course = ? WHERE id = ?`,
|
||||
args: [
|
||||
@@ -2031,9 +2120,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
deleteLocationSample: nessaProcedure
|
||||
.input(locationSampleSchema.pick({ id: true }))
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const sample = await conn.execute({
|
||||
sql: "SELECT workoutId FROM locationSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (sample.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Location sample not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (sample.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM locationSamples WHERE id = ?",
|
||||
args: [input.id]
|
||||
@@ -2050,9 +2147,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
createWorkoutSplit: nessaProcedure
|
||||
.input(workoutSplitSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertWorkoutOwned(conn, input.workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -2082,9 +2180,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
updateWorkoutSplit: nessaProcedure
|
||||
.input(workoutSplitSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const split = await conn.execute({
|
||||
sql: "SELECT workoutId FROM workoutSplits WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (split.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Workout split not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (split.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `UPDATE workoutSplits SET splitNumber = ?, distanceMeters = ?, durationSeconds = ?, startTimestamp = ?, endTimestamp = ?, averageHeartRate = ?, averagePace = ?, elevationGain = ?, elevationLoss = ? WHERE id = ?`,
|
||||
args: [
|
||||
@@ -2112,9 +2218,17 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
deleteWorkoutSplit: nessaProcedure
|
||||
.input(workoutSplitSchema.pick({ id: true }))
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
const split = await conn.execute({
|
||||
sql: "SELECT workoutId FROM workoutSplits WHERE id = ?",
|
||||
args: [input.id]
|
||||
});
|
||||
if (split.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Workout split not found" });
|
||||
}
|
||||
await assertWorkoutOwned(conn, (split.rows[0] as any).workoutId, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM workoutSplits WHERE id = ?",
|
||||
args: [input.id]
|
||||
@@ -2131,14 +2245,18 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
createExerciseLibrary: nessaProcedure
|
||||
.input(exerciseLibrarySchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" });
|
||||
}
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO exerciseLibrary (id, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
input.id,
|
||||
input.userId,
|
||||
input.name,
|
||||
input.category,
|
||||
input.muscleGroups ?? null,
|
||||
@@ -2162,9 +2280,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
updateExerciseLibrary: nessaProcedure
|
||||
.input(exerciseLibrarySchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertExerciseLibraryOwned(conn, input.id, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `UPDATE exerciseLibrary SET name = ?, category = ?, muscleGroups = ?, equipment = ?, instructions = ?, defaultSets = ?, defaultReps = ?, defaultRestSeconds = ?, notes = ?, updatedAt = datetime('now') WHERE id = ?`,
|
||||
args: [
|
||||
@@ -2192,9 +2311,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
deleteExerciseLibrary: nessaProcedure
|
||||
.input(exerciseIdSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertExerciseLibraryOwned(conn, input.id, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM exerciseLibrary WHERE id = ?",
|
||||
args: [input.id]
|
||||
@@ -2211,7 +2331,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
createAuthProvider: nessaProcedure
|
||||
.input(providerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" });
|
||||
}
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await conn.execute({
|
||||
@@ -2239,9 +2362,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
updateAuthProvider: nessaProcedure
|
||||
.input(providerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertAuthProviderOwned(conn, input.id, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: `UPDATE authProviders SET provider = ?, providerUserId = ?, email = ?, displayName = ?, avatarUrl = ?, lastUsedAt = datetime('now') WHERE id = ?`,
|
||||
args: [
|
||||
@@ -2265,9 +2389,10 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
deleteAuthProvider: nessaProcedure
|
||||
.input(providerSchema.pick({ id: true }))
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
await assertAuthProviderOwned(conn, input.id, ctx.nessaUserId);
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM authProviders WHERE id = ?",
|
||||
args: [input.id]
|
||||
@@ -2318,12 +2443,19 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
|
||||
if (input.exerciseLibrary?.length) {
|
||||
for (const exercise of input.exerciseLibrary) {
|
||||
if (exercise.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO exerciseLibrary (id, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
exercise.id,
|
||||
exercise.userId,
|
||||
exercise.name,
|
||||
exercise.category,
|
||||
exercise.muscleGroups ?? null,
|
||||
|
||||
Reference in New Issue
Block a user