diff --git a/.opencode/openwork.json b/.opencode/openwork.json
new file mode 100644
index 00000000..7642282d
--- /dev/null
+++ b/.opencode/openwork.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "workspace": {
+ "name": "zerofinance",
+ "createdAt": 1768940142851,
+ "preset": "starter"
+ },
+ "authorizedRoots": [
+ "/Users/benjaminshafii/git/zerofinance"
+ ]
+}
\ No newline at end of file
diff --git a/.opencode/plugin/test-parser.ts b/.opencode/plugin/test-parser.ts
index 5c1e58db..d18bc5e0 100644
--- a/.opencode/plugin/test-parser.ts
+++ b/.opencode/plugin/test-parser.ts
@@ -48,7 +48,7 @@ const testCases = [
`Some text before
- learned: Vercel logs require --scope prologe flag
-- skill: debug-prod-data
+- skill: debug-prod-issues
- improvement: Add note about scope flag
Some text after`,
diff --git a/.opencode/skill/debug-prod-data/SKILL.md b/.opencode/skill/debug-prod-issues/SKILL.md
similarity index 100%
rename from .opencode/skill/debug-prod-data/SKILL.md
rename to .opencode/skill/debug-prod-issues/SKILL.md
diff --git a/.opencode/skill/testability/SKILL.md b/.opencode/skill/testability/SKILL.md
index a96e1e72..03eb45cb 100644
--- a/.opencode/skill/testability/SKILL.md
+++ b/.opencode/skill/testability/SKILL.md
@@ -94,6 +94,8 @@ pnpm test:watch # Watch mode
pnpm test -- --run --grep "fee" # Filter by name
```
+> Repo note: `@zero-finance/web` Vitest discovers tests under `packages/web/src/test/**/*.test.ts`. Put new tests there (or update Vitest config) so they get picked up.
+
---
## Layer 2: API/Integration Tests
diff --git a/.opencode/skill/vercel-dns/SKILL.md b/.opencode/skill/vercel-dns/SKILL.md
index 5ddf1bbe..5285e299 100644
--- a/.opencode/skill/vercel-dns/SKILL.md
+++ b/.opencode/skill/vercel-dns/SKILL.md
@@ -73,11 +73,12 @@ vercel dns add CNAME
vercel dns add TXT ''
# Add MX record with priority
-vercel dns add MX ''
+# NOTE: MX priority is a separate argument (not embedded in the value)
+vercel dns add MX
# Add record at apex (root domain) - use empty string or @
vercel dns add '' TXT ''
-vercel dns add @ MX '10 mail.example.com'
+vercel dns add @ MX mail.example.com 10
```
### Remove DNS Records
@@ -99,7 +100,12 @@ vercel dns rm --yes
```bash
# Add MX record for receiving email
-vercel dns add example.com '' MX '10 inbound-smtp.us-east-1.amazonaws.com'
+# WARNING: Setting MX at the apex (root) will route *all* inbound mail for the domain.
+# If the domain already uses Google Workspace / Fastmail / etc, prefer a dedicated subdomain.
+vercel dns add example.com '' MX inbound-smtp.us-east-1.amazonaws.com 10
+
+# Safer: use a subdomain for inbound routing
+vercel dns add example.com inbound MX inbound-smtp.us-east-1.amazonaws.com 10
# Add SPF record
vercel dns add example.com '' TXT 'v=spf1 include:amazonses.com ~all'
@@ -148,9 +154,22 @@ vercel switch
### Record Not Propagating
- DNS propagation can take up to 48 hours (usually 5-30 minutes)
-- Check propagation: `dig ` or use dnschecker.org
+- Check propagation: `dig +short ` or use dnschecker.org
- Verify record was added: `vercel dns ls `
+## Token Saving Tips
+
+### Fast Verification With `dig`
+
+When a third-party UI says "Looking for DNS records", verify what the public internet sees:
+
+```bash
+dig +short TXT resend._domainkey.example.com
+dig +short TXT send.example.com
+dig +short MX send.example.com
+dig +short TXT example.com
+```
+
## Tips
1. Always check which team you're in before making changes
diff --git a/.opencode/skill/workspace-guide/SKILL.md b/.opencode/skill/workspace-guide/SKILL.md
new file mode 100644
index 00000000..7443fc4f
--- /dev/null
+++ b/.opencode/skill/workspace-guide/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: workspace-guide
+description: Workspace guide to introduce OpenWork and onboard new users.
+---
+
+# Welcome to OpenWork
+
+Hi, I\'m Ben and this is OpenWork. It\'s an open-source alternative to Claude\'s cowork. It helps you work on your files with AI and automate the mundane tasks so you don\'t have to.
+
+Before we start, use the question tool to ask:
+"Are you more technical or non-technical? I\'ll tailor the explanation."
+
+## If the person is non-technical
+OpenWork feels like a chat app, but it can safely work with the files you allow. Put files in this workspace and I can summarize them, create new ones, or help organize them.
+
+Try:
+- "Summarize the files in this workspace."
+- "Create a checklist for my week."
+- "Draft a short summary from this document."
+
+## Skills and plugins (simple)
+Skills add new capabilities. Plugins add advanced features like scheduling or browser automation. We can add them later when you\'re ready.
+
+## If the person is technical
+OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here.
+
+Most reliable setup today:
+1) Install OpenCode from opencode.ai
+2) Configure providers there (models and API keys)
+3) Come back to OpenWork and start a session
+
+Skills:
+- Install from the Skills tab, or add them to this workspace.
+- Docs: https://opencode.ai/docs/skills
+
+Plugins:
+- Configure in opencode.json or use the Plugins tab.
+- Docs: https://opencode.ai/docs/plugins/
+
+MCP servers:
+- Add external tools via opencode.json.
+- Docs: https://opencode.ai/docs/mcp-servers/
+
+Config reference:
+- Docs: https://opencode.ai/docs/config/
+
+End with two friendly next actions to try in OpenWork.
\ No newline at end of file
diff --git a/.openwork/templates/interact-with-files/template.yml b/.openwork/templates/interact-with-files/template.yml
new file mode 100644
index 00000000..69db7d33
--- /dev/null
+++ b/.openwork/templates/interact-with-files/template.yml
@@ -0,0 +1,7 @@
+---
+id: 'interact-with-files'
+title: 'Learn to interact with files'
+description: 'Safe, practical file workflows'
+createdAt: 1768940142850
+---
+Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.
diff --git a/.openwork/templates/learn-plugins/template.yml b/.openwork/templates/learn-plugins/template.yml
new file mode 100644
index 00000000..76572963
--- /dev/null
+++ b/.openwork/templates/learn-plugins/template.yml
@@ -0,0 +1,7 @@
+---
+id: 'learn-plugins'
+title: 'Learn about plugins'
+description: 'What plugins are and how to install them'
+createdAt: 1768940142850
+---
+Explain what plugins are and how to install them in this workspace.
diff --git a/.openwork/templates/learn-skills/template.yml b/.openwork/templates/learn-skills/template.yml
new file mode 100644
index 00000000..2c6133fc
--- /dev/null
+++ b/.openwork/templates/learn-skills/template.yml
@@ -0,0 +1,7 @@
+---
+id: 'learn-skills'
+title: 'Learn about skills'
+description: 'How skills work and how to create your own'
+createdAt: 1768940142850
+---
+Explain what skills are, how to use them, and how to create a new skill for this workspace.
diff --git a/opencode.json b/opencode.json
index dadd4283..829dcd09 100644
--- a/opencode.json
+++ b/opencode.json
@@ -2,23 +2,23 @@
"$schema": "https://opencode.ai/config.json",
"mcp": {
"exa": {
+ "headers": {},
"type": "remote",
- "url": "https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa",
- "headers": {}
+ "url": "https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa"
},
"notion": {
+ "enabled": true,
+ "oauth": {},
"type": "remote",
- "url": "https://mcp.notion.com/mcp",
- "enabled": true
+ "url": "https://mcp.notion.com/mcp"
},
"zero-finance": {
- "type": "remote",
- "url": "https://www.0.finance/api/mcp",
"headers": {
- "Authorization": "Bearer "
+ "Authorization": "Bearer {env:ZERO_FINANCE_MCP_TOKEN}"
},
- "enabled": true
+ "type": "remote",
+ "url": "https://www.0.finance/api/mcp"
}
},
- "plugin": ["@different-ai/opencode-browser"]
+ "plugin": ["opencode-scheduler"]
}
diff --git a/packages/web/drizzle/0131_insurance_coverage_amount.sql b/packages/web/drizzle/0131_insurance_coverage_amount.sql
new file mode 100644
index 00000000..6f621c5c
--- /dev/null
+++ b/packages/web/drizzle/0131_insurance_coverage_amount.sql
@@ -0,0 +1 @@
+ALTER TABLE "workspaces" ADD COLUMN "insurance_coverage_usd" integer DEFAULT 100000 NOT NULL;
diff --git a/packages/web/src/app/(authenticated)/dashboard/insurance/activate/page.tsx b/packages/web/src/app/(authenticated)/dashboard/insurance/activate/page.tsx
index 9b53c81f..38679c99 100644
--- a/packages/web/src/app/(authenticated)/dashboard/insurance/activate/page.tsx
+++ b/packages/web/src/app/(authenticated)/dashboard/insurance/activate/page.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
-import { useRouter } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/trpc/react';
import {
@@ -15,8 +15,19 @@ import {
import GeneratedComponent from '@/app/(landing)/welcome-gradient';
import { toast } from 'sonner';
+import {
+ formatCoverageUsd,
+ parseCoverageAmountParam,
+} from '@/lib/insurance/coverage-amount';
+
export default function InsuranceActivatePage() {
const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const coverageUsd = parseCoverageAmountParam(
+ searchParams.get('coverage') ?? searchParams.get('amount') ?? undefined,
+ );
+ const coverageDisplay = formatCoverageUsd(coverageUsd, { style: 'compact' });
const [step, setStep] = useState<'initial' | 'loading' | 'success'>(
'initial',
);
@@ -61,7 +72,7 @@ export default function InsuranceActivatePage() {
setCurrentLoadingStep(0);
try {
- await activateInsurance.mutateAsync(undefined);
+ await activateInsurance.mutateAsync({ coverageUsd });
// Invalidate user profile to refresh insurance status
await utils.user.getProfile.invalidate();
@@ -122,7 +133,7 @@ export default function InsuranceActivatePage() {
I have read and agree to the{' '}
@@ -130,9 +141,9 @@ export default function InsuranceActivatePage() {
{' '}
for the DeFi Protection Security Guarantee. I understand
that only Morpho vaults are covered through this interface,
- insurance (up to $1M) is provided by Chainproof (a licensed
- insurer), and smart contract audits are performed by
- Quantstamp.
+ insurance (up to {coverageDisplay}) is provided by
+ Chainproof (a licensed insurer), and smart contract audits
+ are performed by Quantstamp.
diff --git a/packages/web/src/app/terms-of-service/page.tsx b/packages/web/src/app/terms-of-service/page.tsx
index 60aefcc2..1a5ef874 100644
--- a/packages/web/src/app/terms-of-service/page.tsx
+++ b/packages/web/src/app/terms-of-service/page.tsx
@@ -1,5 +1,10 @@
import type { Metadata } from 'next';
+import {
+ formatCoverageUsd,
+ getCoverageUsdFromSearchParams,
+} from '@/lib/insurance/coverage-amount';
+
export const metadata: Metadata = {
title: 'Terms and Conditions – DeFi Protection Security Guarantee',
description:
@@ -7,7 +12,15 @@ export const metadata: Metadata = {
robots: { index: true, follow: true },
};
-export default function TermsOfServicePage() {
+export default function TermsOfServicePage({
+ searchParams,
+}: {
+ searchParams: Record;
+}) {
+ const coverageUsd = getCoverageUsdFromSearchParams(searchParams);
+ const coverageCompact = formatCoverageUsd(coverageUsd, { style: 'compact' });
+ const coverageFull = formatCoverageUsd(coverageUsd, { style: 'full' });
+
return (
Terms and Conditions (DeFi Protection Security Guarantee)
@@ -49,9 +62,9 @@ export default function TermsOfServicePage() {
("Blockchain").
- Insurance and Security: Insurance coverage (up to $1M)
- is provided by Chainproof, a licensed insurer. Smart contract security
- audits are performed by Quantstamp.{' '}
+ Insurance and Security: Insurance coverage (up to{' '}
+ {coverageCompact}) is provided by Chainproof, a licensed insurer. Smart
+ contract security audits are performed by Quantstamp.{' '}
View full insurance terms
@@ -64,8 +77,8 @@ export default function TermsOfServicePage() {
accrued interest or yield, less any liabilities that you owe to the DeFi
protocol or other users of the DeFi protocol, or (ii) your chosen
Maximum Reimbursement amount (each in USD) ("Damages"). In no event will
- our liability for Damages exceed $1,000,000 in the aggregate across any
- one Protocol.
+ our liability for Damages exceed {coverageFull} in the aggregate across
+ any one Protocol.
Valuation Methodology
@@ -93,7 +106,7 @@ export default function TermsOfServicePage() {
You may only purchase a Security Services Guarantee through one Wallet
per Protocol; however, you may increase your Wallet's Maximum
- Reimbursement amount at any time, up to a maximum of $1,000,000. Any
+ Reimbursement amount at any time, up to a maximum of {coverageFull}. Any
violation of these terms may result in the immediate cancellation of any
or all of your Contracts, and any of our obligations of reimbursement
will be void.
diff --git a/packages/web/src/db/schema/workspaces.ts b/packages/web/src/db/schema/workspaces.ts
index 98f629bc..d1c84b29 100644
--- a/packages/web/src/db/schema/workspaces.ts
+++ b/packages/web/src/db/schema/workspaces.ts
@@ -4,6 +4,7 @@ import {
uuid,
timestamp,
boolean,
+ integer,
uniqueIndex,
index,
text,
@@ -68,6 +69,9 @@ export const workspaces = pgTable(
withTimezone: true,
}),
insuranceActivatedBy: varchar('insurance_activated_by', { length: 255 }),
+ insuranceCoverageUsd: integer('insurance_coverage_usd')
+ .default(100_000)
+ .notNull(),
// AI Email Handle - human-readable email address for AI agent
// Format: ai-{firstname}.{lastname} (e.g., ai-clara.mitchell)
diff --git a/packages/web/src/lib/insurance/coverage-amount.ts b/packages/web/src/lib/insurance/coverage-amount.ts
new file mode 100644
index 00000000..5f5c276d
--- /dev/null
+++ b/packages/web/src/lib/insurance/coverage-amount.ts
@@ -0,0 +1,96 @@
+const DEFAULT_COVERAGE_USD = 100_000;
+
+function normalizeAmountString(raw: string): string {
+ return raw
+ .trim()
+ .toLowerCase()
+ .replace(/[$,\s_]/g, '');
+}
+
+/**
+ * Parse a coverage amount from a query param.
+ *
+ * Supported inputs:
+ * - 100000
+ * - "100000"
+ * - "100k"
+ * - "1m"
+ *
+ * Notes:
+ * - Returns an integer USD amount.
+ * - Clamped to [0, 1_000_000].
+ */
+export function parseCoverageAmountParam(value: unknown): number {
+ if (Array.isArray(value)) {
+ return parseCoverageAmountParam(value[0]);
+ }
+
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return clampCoverage(Math.round(value));
+ }
+
+ if (typeof value !== 'string') {
+ return DEFAULT_COVERAGE_USD;
+ }
+
+ const normalized = normalizeAmountString(value);
+ if (!normalized) {
+ return DEFAULT_COVERAGE_USD;
+ }
+
+ const match = normalized.match(/^([0-9]+(?:\.[0-9]+)?)([km])?$/);
+ if (!match) {
+ return DEFAULT_COVERAGE_USD;
+ }
+
+ const num = Number(match[1]);
+ if (!Number.isFinite(num)) {
+ return DEFAULT_COVERAGE_USD;
+ }
+
+ const suffix = match[2];
+ const multiplier = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1;
+ return clampCoverage(Math.round(num * multiplier));
+}
+
+function clampCoverage(amount: number): number {
+ return Math.min(1_000_000, Math.max(0, amount));
+}
+
+export function formatCoverageUsd(
+ amountUsd: number,
+ opts?: { style?: 'compact' | 'full' },
+): string {
+ const style = opts?.style ?? 'full';
+
+ if (style === 'compact') {
+ const formatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ notation: 'compact',
+ maximumFractionDigits: 0,
+ }).format(amountUsd);
+
+ // Prefer "$100k" over "$100K".
+ return formatted.replace(/K\b/g, 'k');
+ }
+
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amountUsd);
+}
+
+export function getCoverageUsdFromSearchParams(
+ searchParams: Record,
+): number {
+ const value =
+ (searchParams.coverage as unknown) ??
+ (searchParams.amount as unknown) ??
+ (searchParams.max as unknown);
+ return parseCoverageAmountParam(value);
+}
+
+export const DEFAULT_COVERAGE_AMOUNT_USD = DEFAULT_COVERAGE_USD;
diff --git a/packages/web/src/server/routers/user-router.ts b/packages/web/src/server/routers/user-router.ts
index 1d2fcf86..724d8f65 100644
--- a/packages/web/src/server/routers/user-router.ts
+++ b/packages/web/src/server/routers/user-router.ts
@@ -7,6 +7,8 @@ import type { Context } from '@/server/context';
import { protectedProcedure, router, publicProcedure } from '../create-router';
import { TRPCError } from '@trpc/server';
+import { parseCoverageAmountParam } from '@/lib/insurance/coverage-amount';
+
// Define input type explicitly for better clarity
const SyncInputSchema = z.object({
privyUserId: z.string(),
@@ -262,24 +264,52 @@ export const userRouter = router({
}),
// Activate insurance for user (removes all warnings)
- activateInsurance: protectedProcedure.mutation(async ({ ctx }) => {
- const privyDid = ctx.userId;
- if (!privyDid) {
- throw new TRPCError({ code: 'UNAUTHORIZED' });
- }
+ activateInsurance: protectedProcedure
+ .input(
+ z
+ .object({
+ coverageUsd: z.number().int().min(0).max(1_000_000).optional(),
+ })
+ .optional(),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const privyDid = ctx.userId;
+ if (!privyDid) {
+ throw new TRPCError({ code: 'UNAUTHORIZED' });
+ }
- // Update the user profile to set isInsured = true
- await db
- .update(userProfilesTable)
- .set({
- isInsured: true,
- insuranceActivatedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(userProfilesTable.privyDid, privyDid));
+ const workspaceId = ctx.workspaceId;
+ if (!workspaceId) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Workspace context is unavailable.',
+ });
+ }
- return { success: true, message: 'Insurance activated successfully' };
- }),
+ const coverageUsd = parseCoverageAmountParam(input?.coverageUsd);
+
+ // Update the user profile to set isInsured = true
+ await db
+ .update(userProfilesTable)
+ .set({
+ isInsured: true,
+ insuranceActivatedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(userProfilesTable.privyDid, privyDid));
+
+ await db
+ .update(workspaces)
+ .set({
+ insuranceCoverageUsd: coverageUsd,
+ insuranceActivatedAt: new Date(),
+ insuranceActivatedBy: privyDid,
+ updatedAt: new Date(),
+ })
+ .where(eq(workspaces.id, workspaceId));
+
+ return { success: true, message: 'Insurance activated successfully' };
+ }),
// Update smart wallet address (called on login to store Privy smart wallet)
updateSmartWalletAddress: protectedProcedure
diff --git a/packages/web/src/test/coverage-amount.test.ts b/packages/web/src/test/coverage-amount.test.ts
new file mode 100644
index 00000000..64f4ee61
--- /dev/null
+++ b/packages/web/src/test/coverage-amount.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ formatCoverageUsd,
+ parseCoverageAmountParam,
+} from '../lib/insurance/coverage-amount';
+
+describe('parseCoverageAmountParam', () => {
+ it('defaults when missing', () => {
+ expect(parseCoverageAmountParam(undefined)).toBe(100_000);
+ });
+
+ it('parses integers', () => {
+ expect(parseCoverageAmountParam('250000')).toBe(250_000);
+ });
+
+ it('handles repeated query params', () => {
+ expect(parseCoverageAmountParam(['250000'])).toBe(250_000);
+ });
+
+ it('parses k and m suffixes', () => {
+ expect(parseCoverageAmountParam('100k')).toBe(100_000);
+ expect(parseCoverageAmountParam('1m')).toBe(1_000_000);
+ expect(parseCoverageAmountParam('0.5m')).toBe(500_000);
+ });
+
+ it('clamps to 1,000,000', () => {
+ expect(parseCoverageAmountParam('2000000')).toBe(1_000_000);
+ });
+});
+
+describe('formatCoverageUsd', () => {
+ it('formats full USD without cents', () => {
+ expect(formatCoverageUsd(100_000, { style: 'full' })).toBe('$100,000');
+ });
+
+ it('formats compact USD', () => {
+ expect(formatCoverageUsd(100_000, { style: 'compact' })).toBe('$100k');
+ });
+});