Initial commit for project Spoon!
Build and Push Next App / quality (push) Failing after 45s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 17:52:02 -05:00
commit cf7ff2ee4e
268 changed files with 32981 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.env.local
.env
+92
View File
@@ -0,0 +1,92 @@
# Welcome to your Convex functions directory!
Write your Convex functions here. See
https://docs.convex.dev/using/writing-convex-functions for more.
A query function that takes two arguments looks like:
```ts
// functions.js
import { v } from 'convex/values';
import { query } from './_generated/server';
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query('tablename').collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: 'hello',
});
```
A mutation function looks like:
```ts
// functions.js
import { v } from 'convex/values';
import { mutation } from './_generated/server';
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert('messages', message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' });
// OR
// use the result once the mutation has completed
mutation({ first: 'Hello!', second: 'me' }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.
+62
View File
@@ -0,0 +1,62 @@
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const ownerId = await getRequiredUserId(ctx);
return await ctx.db
.query('agentRequests')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(limit ?? 25);
},
});
export const create = mutation({
args: {
spoonId: v.id('spoons'),
prompt: v.string(),
targetBranch: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const now = Date.now();
return await ctx.db.insert('agentRequests', {
spoonId: args.spoonId,
ownerId,
prompt: requireText(args.prompt, 'Prompt'),
status: 'queued',
targetBranch: optionalText(args.targetBranch),
createdAt: now,
updatedAt: now,
});
},
});
export const cancel = mutation({
args: { requestId: v.id('agentRequests') },
handler: async (ctx, { requestId }) => {
const ownerId = await getRequiredUserId(ctx);
const request = await ctx.db.get(requestId);
if (request?.ownerId !== ownerId) {
throw new ConvexError('Agent request not found.');
}
if (request.status !== 'draft' && request.status !== 'queued') {
throw new ConvexError('Only draft or queued requests can be cancelled.');
}
await ctx.db.patch(requestId, {
status: 'cancelled',
updatedAt: Date.now(),
});
return { success: true };
},
});
+8
View File
@@ -0,0 +1,8 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: 'convex',
},
],
};
+122
View File
@@ -0,0 +1,122 @@
import Authentik from '@auth/core/providers/authentik';
import {
convexAuth,
getAuthUserId,
modifyAccountCredentials,
retrieveAccount,
} from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
import { api } from './_generated/api';
import { action, mutation, query } from './_generated/server';
import { Password, validatePassword } from './custom/auth';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Authentik({
allowDangerousEmailAccountLinking: true,
clientId: process.env.AUTH_AUTHENTIK_ID,
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
Password,
],
});
const getUserById = async (
ctx: QueryCtx,
userId: Id<'users'>,
): Promise<Doc<'users'>> => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
return user;
};
const getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const authAccount = await ctx.db
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.first();
if (!authAccount) throw new ConvexError('Auth account not found');
return authAccount;
};
export const getUserProvider = query({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const userId = args.userId ?? (await getAuthUserId(ctx));
if (!userId) return null;
const authAccount = await getAuthAccountById(ctx, userId);
return authAccount.provider;
},
});
export const getUser = query({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const userId = args.userId ?? (await getAuthUserId(ctx));
if (!userId) return null;
return getUserById(ctx, userId);
},
});
export const updateUser = mutation({
args: {
name: v.optional(v.string()),
email: v.optional(v.string()),
image: v.optional(v.id('_storage')),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const patch: Partial<{
name: string;
email: string;
image: Id<'_storage'>;
}> = {};
if (args.name !== undefined) patch.name = args.name;
if (args.email !== undefined) patch.email = args.email;
if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image;
if (oldImage && oldImage !== args.image) {
await ctx.storage.delete(oldImage);
}
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(userId, patch);
}
return { success: true };
},
});
export const updateUserPassword = action({
args: {
currentPassword: v.string(),
newPassword: v.string(),
},
handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.runQuery(api.auth.getUser, { userId });
if (!user?.email) throw new ConvexError('User not found.');
await retrieveAccount(ctx, {
provider: 'password',
account: { id: user.email, secret: currentPassword },
});
if (!validatePassword(newPassword))
throw new ConvexError('Invalid password.');
await modifyAccountCredentials(ctx, {
provider: 'password',
account: { id: user.email, secret: newPassword },
});
return { success: true };
},
});
+21
View File
@@ -0,0 +1,21 @@
import { cronJobs } from 'convex/server';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs();
/* Example cron jobs
crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT
// Only on weekdays
'Schedule Automatic Lunches',
'0 13 * * 1-5',
api.statuses.automaticLunch,
);
crons.cron(
// Run at 4:00 PM CST / 5:00 PM CDT
// Only on weekdays
'End of shift (weekdays 5pm CT)',
'0 22 * * 1-5',
api.statuses.endOfShiftUpdate,
);
*/
export default crons;
@@ -0,0 +1,2 @@
export { Password, validatePassword } from './providers/password';
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';
@@ -0,0 +1,34 @@
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { ConvexError } from 'convex/values';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { DataModel } from '../../../_generated/dataModel';
export const Password = DefaultPassword<DataModel>({
profile: (params) => ({
email: params.email as string,
name: params.name as string,
}),
validatePasswordRequirements: (password: string) => {
if (!validatePassword(password)) {
throw new ConvexError('Invalid password.');
}
},
reset: UseSendOTPPasswordReset,
verify: UseSendOTP,
});
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
/\s/.test(password) ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password) ||
!/[\p{P}\p{S}]/u.test(password)
) {
return false;
}
return true;
};
@@ -0,0 +1,94 @@
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
import { alphabet } from 'oslo/crypto';
import { UseSend } from 'usesend-js';
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
return {
id: 'usesend',
type: 'email',
name: 'UseSend',
from: process.env.USESEND_FROM_EMAIL ?? 'noreply@example.com',
maxAge: 24 * 60 * 60, // 24 hours
generateVerificationToken: () => {
const random: RandomReader = {
read: (bytes) => {
crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);
},
};
return generateRandomString(random, alphabet('0-9'), 6);
},
sendVerificationRequest: async (params) => {
const { identifier: to, provider, url, token } = params;
// Derive a display name from the site URL, fallback to 'App'
const siteUrl = process.env.USESEND_FROM_EMAIL ?? '';
const appName = siteUrl.split('@')[1]?.split('.')[0] ?? 'App';
const apiKey = process.env.USESEND_API_KEY;
const useSendUrl = process.env.USESEND_URL;
if (!apiKey || !useSendUrl) {
throw new Error('USESEND_API_KEY and USESEND_URL must be set.');
}
const useSend = new UseSend(apiKey, useSendUrl);
// For password reset, we want to send the code, not the magic link
const isPasswordReset =
url.includes('reset') || provider.id.includes('reset');
const result = await useSend.emails.send({
from: provider.from ?? 'noreply@example.com',
to: [to],
subject: isPasswordReset
? `Reset your password - ${appName}`
: `Sign in to ${appName}`,
text: isPasswordReset
? `Your password reset code is ${token}`
: `Your sign in code is ${token}`,
html: isPasswordReset
? `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Password Reset Request</h2>
<p>You requested a password reset. Your reset code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
</div>
`
: `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Your Sign In Code</h2>
<p>Your verification code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 24 hours.</p>
</div>
`,
});
if (result.error) {
throw new Error('UseSend error: ' + JSON.stringify(result.error));
}
},
options: config,
};
}
// Create specific instances for password reset and email verification
export const UseSendOTPPasswordReset = UseSendProvider({
id: 'usesend-otp-password-reset',
apiKey: process.env.USESEND_API_KEY,
maxAge: 60 * 60, // 1 hour
});
export const UseSendOTP = UseSendProvider({
id: 'usesend-otp',
apiKey: process.env.USESEND_API_KEY,
maxAge: 60 * 20, // 20 minutes
});
+18
View File
@@ -0,0 +1,18 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const generateUploadUrl = mutation(async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return await ctx.storage.generateUploadUrl();
});
export const getImageUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);
return url ?? null;
},
});
+10
View File
@@ -0,0 +1,10 @@
// Declare process.env for Convex backend environment variables.
// Convex supports process.env to read variables set in the Convex Dashboard.
declare const process: {
readonly env: {
readonly USESEND_API_KEY?: string;
readonly USESEND_URL?: string;
readonly USESEND_FROM_EMAIL?: string;
readonly [key: string]: string | undefined;
};
};
+9
View File
@@ -0,0 +1,9 @@
import { httpRouter } from 'convex/server';
import { auth } from './auth';
const http = httpRouter();
auth.addHttpRoutes(http);
export default http;
+37
View File
@@ -0,0 +1,37 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
type Ctx = QueryCtx | MutationCtx;
export const getRequiredUserId = async (ctx: Ctx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
export const getOwnedSpoon = async (
ctx: Ctx,
spoonId: Id<'spoons'>,
ownerId: Id<'users'>,
): Promise<Doc<'spoons'>> => {
const spoon = await ctx.db.get(spoonId);
if (spoon?.ownerId !== ownerId) {
throw new ConvexError('Spoon not found.');
}
return spoon;
};
export const requireText = (value: string, label: string): string => {
const trimmed = value.trim();
if (!trimmed) throw new ConvexError(`${label} is required.`);
return trimmed;
};
export const optionalText = (value: string | undefined) => {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed;
};
+175
View File
@@ -0,0 +1,175 @@
import { authTables } from '@convex-dev/auth/server';
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
const applicationTables = {
/*
* Below is the users table definition from authTables
* You can add additional fields here. You can also remove
* the users table here & create a 'profiles' table if you
* prefer to keep auth data separate from application data.
*/
users: defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
email: v.optional(v.string()),
emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()),
/* Fields below here are custom & not defined in authTables */
isAdmin: v.optional(v.boolean()),
role: v.optional(v.union(v.literal('owner'), v.literal('member'))),
lastSeenAt: v.optional(v.number()),
themePreference: v.optional(
v.union(v.literal('light'), v.literal('dark'), v.literal('system')),
),
})
.index('email', ['email'])
.index('phone', ['phone'])
/* Indexes below here are custom & not defined in authTables */
.index('name', ['name']),
gitConnections: defineTable({
userId: v.id('users'),
provider: v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
),
providerAccountId: v.optional(v.string()),
displayName: v.string(),
username: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
installationId: v.optional(v.string()),
scopes: v.optional(v.array(v.string())),
status: v.union(
v.literal('active'),
v.literal('needs_reauth'),
v.literal('revoked'),
),
connectedAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider'])
.index('by_status', ['status']),
spoons: defineTable({
ownerId: v.id('users'),
name: v.string(),
description: v.optional(v.string()),
provider: v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.optional(v.string()),
forkRepo: v.optional(v.string()),
forkDefaultBranch: v.optional(v.string()),
forkUrl: v.optional(v.string()),
visibility: v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
),
maintenanceMode: v.union(
v.literal('watch'),
v.literal('auto_pr'),
v.literal('paused'),
),
syncCadence: v.union(
v.literal('daily'),
v.literal('weekly'),
v.literal('manual'),
),
productionRefStrategy: v.union(
v.literal('default_branch'),
v.literal('latest_release'),
v.literal('tag_pattern'),
),
tagPattern: v.optional(v.string()),
status: v.union(
v.literal('draft'),
v.literal('active'),
v.literal('needs_connection'),
v.literal('paused'),
v.literal('archived'),
),
lastCheckedAt: v.optional(v.number()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_owner_provider', ['ownerId', 'provider'])
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
syncRuns: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: v.union(
v.literal('scheduled_check'),
v.literal('manual_check'),
v.literal('upstream_update'),
v.literal('merge_attempt'),
v.literal('ai_review'),
),
status: v.union(
v.literal('queued'),
v.literal('running'),
v.literal('clean'),
v.literal('conflict'),
v.literal('needs_review'),
v.literal('failed'),
v.literal('merged'),
),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
agentRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
prompt: v.string(),
status: v.union(
v.literal('draft'),
v.literal('queued'),
v.literal('running'),
v.literal('changes_ready'),
v.literal('merge_request_opened'),
v.literal('failed'),
v.literal('cancelled'),
),
targetBranch: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
};
export default defineSchema({
...authTables,
...applicationTables,
});
+178
View File
@@ -0,0 +1,178 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import {
getOwnedSpoon,
getRequiredUserId,
optionalText,
requireText,
} from './model';
const provider = v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
);
const visibility = v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
);
const maintenanceMode = v.union(
v.literal('watch'),
v.literal('auto_pr'),
v.literal('paused'),
);
const syncCadence = v.union(
v.literal('daily'),
v.literal('weekly'),
v.literal('manual'),
);
const productionRefStrategy = v.union(
v.literal('default_branch'),
v.literal('latest_release'),
v.literal('tag_pattern'),
);
const spoonStatus = v.union(
v.literal('draft'),
v.literal('active'),
v.literal('needs_connection'),
v.literal('paused'),
v.literal('archived'),
);
const hasForkMetadata = (args: {
forkOwner?: string;
forkRepo?: string;
forkUrl?: string;
}) =>
Boolean(
args.forkOwner?.trim() && args.forkRepo?.trim() && args.forkUrl?.trim(),
);
export const listMine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const spoons = await ctx.db
.query('spoons')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.collect();
return spoons.filter((spoon) => spoon.status !== 'archived');
},
});
export const get = query({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
return getOwnedSpoon(ctx, spoonId, ownerId);
},
});
export const createManual = mutation({
args: {
name: v.string(),
description: v.optional(v.string()),
provider,
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.optional(v.string()),
forkRepo: v.optional(v.string()),
forkDefaultBranch: v.optional(v.string()),
forkUrl: v.optional(v.string()),
visibility,
maintenanceMode,
syncCadence,
productionRefStrategy,
tagPattern: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const now = Date.now();
const forkOwner = optionalText(args.forkOwner);
const forkRepo = optionalText(args.forkRepo);
const forkUrl = optionalText(args.forkUrl);
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
? 'draft'
: 'needs_connection';
return await ctx.db.insert('spoons', {
ownerId,
name: requireText(args.name, 'Spoon name'),
description: optionalText(args.description),
provider: args.provider,
upstreamOwner: requireText(args.upstreamOwner, 'Upstream owner'),
upstreamRepo: requireText(args.upstreamRepo, 'Upstream repository'),
upstreamDefaultBranch: requireText(
args.upstreamDefaultBranch,
'Upstream default branch',
),
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
forkOwner,
forkRepo,
forkDefaultBranch: optionalText(args.forkDefaultBranch),
forkUrl,
visibility: args.visibility,
maintenanceMode: args.maintenanceMode,
syncCadence: args.syncCadence,
productionRefStrategy: args.productionRefStrategy,
tagPattern: optionalText(args.tagPattern),
status,
createdAt: now,
updatedAt: now,
});
},
});
export const updateSettings = mutation({
args: {
spoonId: v.id('spoons'),
maintenanceMode: v.optional(maintenanceMode),
syncCadence: v.optional(syncCadence),
productionRefStrategy: v.optional(productionRefStrategy),
tagPattern: v.optional(v.string()),
status: v.optional(spoonStatus),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, args.spoonId, ownerId);
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
if (args.maintenanceMode) patch.maintenanceMode = args.maintenanceMode;
if (args.syncCadence) patch.syncCadence = args.syncCadence;
if (args.productionRefStrategy) {
patch.productionRefStrategy = args.productionRefStrategy;
}
if (args.tagPattern !== undefined)
patch.tagPattern = optionalText(args.tagPattern);
if (args.status) patch.status = args.status;
await ctx.db.patch(args.spoonId, patch);
return { success: true };
},
});
export const archive = mutation({
args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => {
const ownerId = await getRequiredUserId(ctx);
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
if (spoon.status === 'archived')
throw new ConvexError('Spoon is archived.');
await ctx.db.patch(spoonId, {
status: 'archived',
updatedAt: Date.now(),
});
return { success: true };
},
});
+30
View File
@@ -0,0 +1,30 @@
import { v } from 'convex/values';
import { query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId } from './model';
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const ownerId = await getRequiredUserId(ctx);
const runs = await ctx.db
.query('syncRuns')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.take(limit ?? 25);
return runs;
},
});
export const listForSpoon = query({
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db
.query('syncRuns')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc')
.take(limit ?? 25);
},
});
+24
View File
@@ -0,0 +1,24 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@spoon/eslint-config/base';
export default defineConfig(
{
ignores: ['convex/_generated/**', 'types/**', 'scripts/**', 'dist/**'],
},
baseConfig,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
);
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@spoon/backend",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "Convex Backend for Spoon",
"author": "Gib",
"license": "MIT",
"exports": {
"./convex": "./convex/",
"./convex/*": "./convex/*",
"./types": "./types/index.ts"
},
"scripts": {
"dev": "bun with-env convex dev",
"dev:tunnel": "bun with-env convex dev",
"dev:web": "bun with-env convex dev",
"setup": "bun with-env convex dev --until-success",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"typecheck": "tsc --noEmit",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration --passWithNoTests",
"test:component": "vitest run --project component --passWithNoTests",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
},
"dependencies": {
"@oslojs/crypto": "^1.0.1",
"@react-email/components": "1.0.10",
"@react-email/render": "^2.0.4",
"convex": "catalog:convex",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"usesend-js": "^1.6.3",
"zod": "catalog:"
},
"devDependencies": {
"@edge-runtime/vm": "catalog:test",
"@spoon/eslint-config": "workspace:*",
"@spoon/prettier-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
"@spoon/vitest-config": "workspace:*",
"@types/node": "catalog:",
"convex-test": "catalog:test",
"eslint": "catalog:",
"prettier": "catalog:",
"react-email": "5.2.10",
"typescript": "catalog:",
"vitest": "catalog:test"
},
"prettier": "@spoon/prettier-config"
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env node
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose';
const keys = await generateKeyPair('RS256', {
extractable: true,
});
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: 'sig', ...publicKey }] });
process.stdout.write(
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, ' ')}"`,
);
process.stdout.write('\n');
process.stdout.write(`JWKS=${jwks}`);
process.stdout.write('\n');
@@ -0,0 +1,92 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import { api } from '../../convex/_generated/api';
import schema from '../../convex/schema';
const modules = import.meta.glob('../../convex/**/*.*s');
const createUser = async (t: ReturnType<typeof convexTest>, email: string) =>
await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
});
const authed = (t: ReturnType<typeof convexTest>, userId: string) =>
t.withIdentity({
subject: `${userId}|session`,
issuer: 'https://convex.test',
});
const spoonInput = {
name: 'Editor Spoon',
provider: 'gitea' as const,
upstreamOwner: 'upstream',
upstreamRepo: 'editor',
upstreamDefaultBranch: 'main',
upstreamUrl: 'https://git.example.com/upstream/editor',
forkOwner: 'team',
forkRepo: 'editor-spoon',
forkUrl: 'https://git.example.com/team/editor-spoon',
visibility: 'private' as const,
maintenanceMode: 'watch' as const,
syncCadence: 'daily' as const,
productionRefStrategy: 'default_branch' as const,
};
describe('convex-test harness', () => {
test('boots and executes against the project schema', async () => {
const t = convexTest(schema, modules);
expect(await t.run(() => Promise.resolve(42))).toBe(42);
});
test('requires authentication to create a Spoon', async () => {
const t = convexTest(schema, modules);
await expect(
t.mutation(api.spoons.createManual, spoonInput),
).rejects.toThrow('Not authenticated.');
});
test('creates and lists Spoons for the current user', async () => {
const t = convexTest(schema, modules);
const userId = await createUser(t, 'one@example.com');
const session = authed(t, userId);
const spoonId = await session.mutation(api.spoons.createManual, spoonInput);
const spoons = await session.query(api.spoons.listMine, {});
expect(spoons).toHaveLength(1);
expect(spoons[0]?._id).toBe(spoonId);
expect(spoons[0]?.ownerId).toBe(userId);
});
test('does not allow reading another users Spoon', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
const otherId = await createUser(t, 'other@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
await expect(
authed(t, otherId).query(api.spoons.get, { spoonId }),
).rejects.toThrow('Spoon not found.');
});
test('requires Spoon ownership for agent requests', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
const otherId = await createUser(t, 'other@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
await expect(
authed(t, otherId).mutation(api.agentRequests.create, {
spoonId,
prompt: 'Add a settings page',
}),
).rejects.toThrow('Spoon not found.');
});
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "@spoon/tsconfig/base.json",
"compilerOptions": { "lib": ["ES2022", "DOM"], "types": ["node"] },
"include": ["tests", "types", "vitest.config.ts"],
"exclude": ["node_modules", "convex/_generated"]
}
+4
View File
@@ -0,0 +1,4 @@
export const PASSWORD_MIN = 8;
export const PASSWORD_MAX = 100;
export const PASSWORD_REGEX =
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
+1
View File
@@ -0,0 +1 @@
export { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from './auth';
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { convexProject, nodeProject } from '@spoon/vitest-config';
export default defineConfig({
test: {
projects: [
convexProject('unit', ['tests/unit/**/*.test.ts']),
convexProject('integration', ['tests/integration/**/*.test.ts']),
nodeProject('component', ['tests/component/**/*.test.{ts,tsx}']),
],
},
});