vibecarbon 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/scripts/dev-init.js +6 -2
- package/carbon/src/client/assets/vibecarbon-icon.svg +6 -6
- package/carbon/src/client/assets/vibecarbon-wordmark-text-dark.svg +12 -0
- package/carbon/src/client/assets/vibecarbon-wordmark-text-light.svg +12 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +1 -3
- package/carbon/src/client/components/AppSidebar.tsx +35 -28
- package/carbon/src/client/components/CTAFooter.tsx +1 -1
- package/carbon/src/client/components/ComparisonSection.tsx +1 -3
- package/carbon/src/client/components/ContentPanel.tsx +1 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +1 -3
- package/carbon/src/client/components/FAQSection.tsx +1 -1
- package/carbon/src/client/components/Hero.tsx +13 -1
- package/carbon/src/client/components/Logo.tsx +66 -49
- package/carbon/src/client/components/Nav.tsx +1 -1
- package/carbon/src/client/components/NewsletterSignup.tsx +1 -1
- package/carbon/src/client/components/NotificationDrawer.tsx +3 -3
- package/carbon/src/client/components/PageHeader.tsx +1 -1
- package/carbon/src/client/components/PricingSection.tsx +2 -6
- package/carbon/src/client/components/TechStackSection.tsx +1 -3
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +5 -0
- package/carbon/src/client/components/ui/button.tsx +1 -1
- package/carbon/src/client/components/ui/card.tsx +1 -1
- package/carbon/src/client/components/ui/dialog.tsx +1 -1
- package/carbon/src/client/components/ui/dropdown-menu.tsx +2 -2
- package/carbon/src/client/components/ui/popover.tsx +1 -1
- package/carbon/src/client/components/ui/select.tsx +1 -1
- package/carbon/src/client/components/ui/sidebar.tsx +1 -1
- package/carbon/src/client/index.css +186 -104
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/lib/theme.ts +14 -14
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +33 -18
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Blog.tsx +1 -1
- package/carbon/src/client/pages/Changelog.tsx +1 -1
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/client/pages/Dashboard.tsx +33 -40
- package/carbon/src/client/pages/Docs.tsx +1 -1
- package/carbon/src/client/pages/Home.tsx +1 -1
- package/carbon/src/client/pages/Legal.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +0 -29
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +0 -29
- package/carbon/src/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
|
@@ -7,30 +7,51 @@ import type { HonoVariables } from '../../types';
|
|
|
7
7
|
|
|
8
8
|
const themeRoutes = new Hono<{ Variables: HonoVariables }>();
|
|
9
9
|
|
|
10
|
-
//
|
|
10
|
+
// SECURITY: theme values are stored via PATCH and served publicly via GET, then
|
|
11
|
+
// the client string-builds a `<style>` block from them (client/lib/theme.ts).
|
|
12
|
+
// Unconstrained strings would allow CSS injection (breaking out of a
|
|
13
|
+
// declaration with `;}` to add arbitrary rules). Constrain each field to a
|
|
14
|
+
// known CSS color/length grammar so no structural characters can escape.
|
|
15
|
+
//
|
|
16
|
+
// Colors: hex (#rgb/#rgba/#rrggbb/#rrggbbaa) or a rgb()/rgba()/hsl()/hsla()/
|
|
17
|
+
// oklch() function whose body is limited to digits, dot, comma, %, slash and
|
|
18
|
+
// whitespace. Radius: a non-negative number with a px/rem/em/% unit, or `0`.
|
|
19
|
+
const COLOR_RE =
|
|
20
|
+
/^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?|oklch)\([\d.,%/\s]+\))$/;
|
|
21
|
+
const RADIUS_RE = /^(0|\d*\.?\d+(px|rem|em|%))$/;
|
|
22
|
+
|
|
23
|
+
const colorField = z.string().regex(COLOR_RE, 'Invalid color value').optional();
|
|
24
|
+
const radiusField = z.string().regex(RADIUS_RE, 'Invalid radius value').optional();
|
|
25
|
+
|
|
11
26
|
const colorSchemaFields = {
|
|
12
|
-
primary:
|
|
13
|
-
primaryDim:
|
|
14
|
-
primaryForeground:
|
|
15
|
-
secondaryAccent:
|
|
16
|
-
secondaryAccentForeground:
|
|
17
|
-
destructive:
|
|
18
|
-
warning:
|
|
19
|
-
success:
|
|
27
|
+
primary: colorField,
|
|
28
|
+
primaryDim: colorField,
|
|
29
|
+
primaryForeground: colorField,
|
|
30
|
+
secondaryAccent: colorField,
|
|
31
|
+
secondaryAccentForeground: colorField,
|
|
32
|
+
destructive: colorField,
|
|
33
|
+
warning: colorField,
|
|
34
|
+
success: colorField,
|
|
20
35
|
};
|
|
21
36
|
|
|
37
|
+
// NOTE: schema is non-strict (unknown keys are stripped, not rejected). The
|
|
38
|
+
// admin UI also sends gradientStart/gradientEnd/card, which are NOT part of the
|
|
39
|
+
// stored/served theme — they've always been stripped here, so the client falls
|
|
40
|
+
// back to its built-in defaults for them. That means they are not a stored-XSS
|
|
41
|
+
// vector (they never persist). Validating them would require intentionally
|
|
42
|
+
// persisting them, which is a product decision, not a security fix — left as-is.
|
|
22
43
|
const themeSchema = z.object({
|
|
23
44
|
light: z.object(colorSchemaFields).optional(),
|
|
24
45
|
dark: z
|
|
25
46
|
.object({
|
|
26
|
-
primary:
|
|
27
|
-
primaryDim:
|
|
28
|
-
primaryForeground:
|
|
29
|
-
secondaryAccent:
|
|
30
|
-
secondaryAccentForeground:
|
|
47
|
+
primary: colorField,
|
|
48
|
+
primaryDim: colorField,
|
|
49
|
+
primaryForeground: colorField,
|
|
50
|
+
secondaryAccent: colorField,
|
|
51
|
+
secondaryAccentForeground: colorField,
|
|
31
52
|
})
|
|
32
53
|
.optional(),
|
|
33
|
-
radius:
|
|
54
|
+
radius: radiusField,
|
|
34
55
|
smoothScrollEnabled: z.boolean().optional(),
|
|
35
56
|
smoothScrollIntensity: z.number().min(0).max(100).optional(),
|
|
36
57
|
});
|
|
@@ -74,7 +74,7 @@ SELECT cron.schedule(
|
|
|
74
74
|
deleted_count INTEGER;
|
|
75
75
|
BEGIN
|
|
76
76
|
DELETE FROM public.failed_login_attempts
|
|
77
|
-
WHERE
|
|
77
|
+
WHERE attempted_at < now() - interval '24 hours';
|
|
78
78
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
79
79
|
PERFORM public.log_cron_job('cleanup-login-attempts', 'succeeded',
|
|
80
80
|
'Deleted ' || deleted_count || ' old login attempts');
|
|
@@ -94,7 +94,7 @@ SELECT cron.schedule(
|
|
|
94
94
|
deleted_count INTEGER;
|
|
95
95
|
BEGIN
|
|
96
96
|
DELETE FROM public.notifications
|
|
97
|
-
WHERE
|
|
97
|
+
WHERE ends_at IS NOT NULL AND ends_at < now();
|
|
98
98
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
99
99
|
PERFORM public.log_cron_job('cleanup-expired-notifications', 'succeeded',
|
|
100
100
|
'Deleted ' || deleted_count || ' expired notifications');
|
|
@@ -26,11 +26,15 @@ CREATE POLICY "Super admins can manage contact submissions"
|
|
|
26
26
|
ON public.contact_submissions FOR ALL
|
|
27
27
|
USING (public.is_super_admin());
|
|
28
28
|
|
|
29
|
-
--
|
|
29
|
+
-- SECURITY: No direct-client (anon/authenticated) write policy exists on this
|
|
30
|
+
-- table. The public contact form POSTs to /api/v1/newsletter/../contact, which
|
|
31
|
+
-- writes via the server's service_role client (BYPASSRLS) — see
|
|
32
|
+
-- routes/v1/contact.ts. A permissive `WITH CHECK (true)` INSERT policy would let
|
|
33
|
+
-- ANY logged-in user (authenticated holds table INSERT grant, roles.sql) inject
|
|
34
|
+
-- rows straight into PostgREST with no legitimate purpose. The DROP below is kept
|
|
35
|
+
-- (without a matching CREATE) so re-applying this migration scrubs any
|
|
36
|
+
-- previously-created permissive policy.
|
|
30
37
|
DROP POLICY IF EXISTS "Anyone can submit contact form" ON public.contact_submissions;
|
|
31
|
-
CREATE POLICY "Anyone can submit contact form"
|
|
32
|
-
ON public.contact_submissions FOR INSERT
|
|
33
|
-
WITH CHECK (true);
|
|
34
38
|
|
|
35
39
|
DROP TRIGGER IF EXISTS update_contact_submissions_updated_at ON public.contact_submissions;
|
|
36
40
|
CREATE TRIGGER update_contact_submissions_updated_at
|
|
@@ -63,18 +67,24 @@ CREATE POLICY "Super admins can manage newsletter subscribers"
|
|
|
63
67
|
ON public.newsletter_subscribers FOR ALL
|
|
64
68
|
USING (public.is_super_admin());
|
|
65
69
|
|
|
66
|
-
--
|
|
70
|
+
-- SECURITY: No direct-client (anon/authenticated) write policy exists on this
|
|
71
|
+
-- table. The ENTIRE newsletter lifecycle is server-mediated via the service_role
|
|
72
|
+
-- client (BYPASSRLS) in routes/v1/newsletter.ts:
|
|
73
|
+
-- - subscribe -> adminDb.upsert(...) (double opt-in, server-set token)
|
|
74
|
+
-- - confirm -> adminDb.update(...).eq('confirmation_token', token)
|
|
75
|
+
-- - unsubscribe-> adminDb.update(...).eq('email', email)
|
|
76
|
+
-- The previous `FOR UPDATE USING (true) WITH CHECK (true)` policy was
|
|
77
|
+
-- world-writable: because `authenticated` holds a table-level UPDATE grant
|
|
78
|
+
-- (roles.sql), any logged-in user could PATCH /rest/v1/newsletter_subscribers
|
|
79
|
+
-- directly and blind-overwrite EVERY subscriber row (status, email, token).
|
|
80
|
+
-- The previous `FOR INSERT WITH CHECK (true)` policy was equally unscoped and let
|
|
81
|
+
-- anyone inject rows / bypass double opt-in — with no legitimate use, since real
|
|
82
|
+
-- signups insert via service_role. Both are removed. The DROPs are kept (without
|
|
83
|
+
-- matching CREATEs) so re-applying this migration scrubs any previously-created
|
|
84
|
+
-- permissive policy. Legitimate access remains: service_role (server) bypasses
|
|
85
|
+
-- RLS; super admins use the "Super admins can manage" policy above.
|
|
67
86
|
DROP POLICY IF EXISTS "Anyone can subscribe to newsletter" ON public.newsletter_subscribers;
|
|
68
|
-
CREATE POLICY "Anyone can subscribe to newsletter"
|
|
69
|
-
ON public.newsletter_subscribers FOR INSERT
|
|
70
|
-
WITH CHECK (true);
|
|
71
|
-
|
|
72
|
-
-- Allow anonymous updates for confirmation/unsubscribe (by token or email)
|
|
73
87
|
DROP POLICY IF EXISTS "Anyone can confirm or unsubscribe" ON public.newsletter_subscribers;
|
|
74
|
-
CREATE POLICY "Anyone can confirm or unsubscribe"
|
|
75
|
-
ON public.newsletter_subscribers FOR UPDATE
|
|
76
|
-
USING (true)
|
|
77
|
-
WITH CHECK (true);
|
|
78
88
|
|
|
79
89
|
DROP TRIGGER IF EXISTS update_newsletter_subscribers_updated_at ON public.newsletter_subscribers;
|
|
80
90
|
CREATE TRIGGER update_newsletter_subscribers_updated_at
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// The supabase module runs its env check at module load, before React mounts.
|
|
4
|
+
// A bare throw leaves #root empty — a completely black page with the only
|
|
5
|
+
// clue buried in the console. The guard must paint a human-readable
|
|
6
|
+
// configuration error into the DOM *and* still throw to halt the app.
|
|
7
|
+
describe('supabase env guard', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
document.body.innerHTML = '<div id="root"></div>';
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllEnvs();
|
|
15
|
+
document.body.innerHTML = '';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders a visible configuration error naming the missing vars before throwing', async () => {
|
|
19
|
+
vi.stubEnv('VITE_SUPABASE_URL', '');
|
|
20
|
+
vi.stubEnv('VITE_SUPABASE_ANON_KEY', '');
|
|
21
|
+
|
|
22
|
+
await expect(import('@/lib/supabase')).rejects.toThrow(
|
|
23
|
+
'Missing Supabase environment variables',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const text = document.body.textContent ?? '';
|
|
27
|
+
expect(text).toContain('Configuration error');
|
|
28
|
+
expect(text).toContain('VITE_SUPABASE_URL');
|
|
29
|
+
expect(text).toContain('VITE_SUPABASE_ANON_KEY');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('names only the vars that are actually missing', async () => {
|
|
33
|
+
vi.stubEnv('VITE_SUPABASE_URL', 'http://localhost:54321');
|
|
34
|
+
vi.stubEnv('VITE_SUPABASE_ANON_KEY', '');
|
|
35
|
+
|
|
36
|
+
await expect(import('@/lib/supabase')).rejects.toThrow(
|
|
37
|
+
'Missing Supabase environment variables',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const text = document.body.textContent ?? '';
|
|
41
|
+
expect(text).toContain('VITE_SUPABASE_ANON_KEY');
|
|
42
|
+
expect(text).not.toContain('VITE_SUPABASE_URL,');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('exports the client and paints nothing when env is present', async () => {
|
|
46
|
+
vi.stubEnv('VITE_SUPABASE_URL', 'http://localhost:54321');
|
|
47
|
+
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
|
48
|
+
|
|
49
|
+
const mod = await import('@/lib/supabase');
|
|
50
|
+
|
|
51
|
+
expect(mod.supabase).toBeDefined();
|
|
52
|
+
expect(document.body.textContent).not.toContain('Configuration error');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('@server/lib/logger', () => ({
|
|
5
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Real rate limiter + real trusted-IP resolver. No REDIS_URL in test env, so it
|
|
9
|
+
// uses the in-memory store. Freeze timers so the InMemoryStore's cleanup
|
|
10
|
+
// interval never fires and all requests land in the same window.
|
|
11
|
+
const { createRateLimiter } = await import('@server/lib/rate-limiter');
|
|
12
|
+
|
|
13
|
+
function appWithLimiter() {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.use('*', createRateLimiter({ windowMs: 60_000, max: 1 }));
|
|
16
|
+
app.get('/x', (c) => c.text('ok'));
|
|
17
|
+
return app;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function xffReq(xff: string) {
|
|
21
|
+
return new Request('http://local.test/x', { headers: { 'x-forwarded-for': xff } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('rate limiter buckets by the trusted client IP, not spoofable XFF', () => {
|
|
32
|
+
it('SECURITY: rotating the spoofable leftmost XFF cannot mint a fresh bucket', async () => {
|
|
33
|
+
const app = appWithLimiter();
|
|
34
|
+
|
|
35
|
+
// First request from real client 198.51.100.7 (rightmost, Traefik-appended).
|
|
36
|
+
const first = await app.request(xffReq('1.1.1.1, 198.51.100.7'));
|
|
37
|
+
expect(first.status).toBe(200);
|
|
38
|
+
|
|
39
|
+
// Second request: attacker rotates the leftmost value hoping for a new bucket.
|
|
40
|
+
// Same real client → same bucket → limited (max: 1).
|
|
41
|
+
const second = await app.request(xffReq('2.2.2.2, 198.51.100.7'));
|
|
42
|
+
expect(second.status).toBe(429);
|
|
43
|
+
|
|
44
|
+
// Control: a genuinely different real client (different rightmost) is a fresh bucket.
|
|
45
|
+
const other = await app.request(xffReq('2.2.2.2, 203.0.113.42'));
|
|
46
|
+
expect(other.status).toBe(200);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { HonoVariables } from '@server/types';
|
|
4
|
+
|
|
5
|
+
// Mutable state driving the mocked billing config + DB rows.
|
|
6
|
+
const { state } = vi.hoisted(() => ({
|
|
7
|
+
state: {
|
|
8
|
+
configured: true,
|
|
9
|
+
// resolveHighestPlan queries: personal customer (maybeSingle), memberships
|
|
10
|
+
// (await), org customers (await), subscriptions (await).
|
|
11
|
+
personalCustomer: null as { id: string } | null,
|
|
12
|
+
memberships: [] as Array<{ organization_id: string | null }>,
|
|
13
|
+
orgCustomers: [] as Array<{ id: string }>,
|
|
14
|
+
subscriptions: [] as Array<{ stripe_price_id: string; status: string }>,
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@server/billing', () => ({
|
|
19
|
+
isBillingConfigured: () => state.configured,
|
|
20
|
+
getProviderPriceIds: () => ({ starter: 'price_starter', pro: 'price_pro' }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('@server/lib/logger', () => ({
|
|
23
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal thenable query-builder double
|
|
27
|
+
function builder(onAwait: any, onMaybeSingle: any): any {
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: builder is intentionally untyped
|
|
29
|
+
const b: any = {};
|
|
30
|
+
for (const m of ['select', 'eq', 'is', 'in', 'order']) b[m] = () => b;
|
|
31
|
+
b.maybeSingle = () => Promise.resolve(onMaybeSingle);
|
|
32
|
+
// Thenable so `await builder` resolves to onAwait.
|
|
33
|
+
b.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
|
|
34
|
+
Promise.resolve(onAwait).then(resolve, reject);
|
|
35
|
+
return b;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
39
|
+
supabaseAdmin: {
|
|
40
|
+
from: (table: string) => {
|
|
41
|
+
if (table === 'customers') {
|
|
42
|
+
// personal path uses maybeSingle; org path is awaited (.in())
|
|
43
|
+
return builder({ data: state.orgCustomers }, { data: state.personalCustomer });
|
|
44
|
+
}
|
|
45
|
+
if (table === 'memberships') return builder({ data: state.memberships }, { data: null });
|
|
46
|
+
if (table === 'subscriptions') return builder({ data: state.subscriptions }, { data: null });
|
|
47
|
+
return builder({ data: null }, { data: null });
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const { requirePlan } = await import('@server/middleware/requirePlan');
|
|
53
|
+
|
|
54
|
+
function app() {
|
|
55
|
+
const a = new Hono<{ Variables: HonoVariables }>();
|
|
56
|
+
a.use('*', async (c, next) => {
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected user
|
|
58
|
+
c.set('user', { id: 'u1', email: 'u@test.com' } as any);
|
|
59
|
+
await next();
|
|
60
|
+
});
|
|
61
|
+
a.get('/pro', requirePlan('pro'), (c) => c.text('ok'));
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
state.configured = true;
|
|
67
|
+
state.personalCustomer = null;
|
|
68
|
+
state.memberships = [];
|
|
69
|
+
state.orgCustomers = [];
|
|
70
|
+
state.subscriptions = [];
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('requirePlan (provider-agnostic + org-level subscriptions)', () => {
|
|
74
|
+
it('allows all requests when billing is not configured', async () => {
|
|
75
|
+
state.configured = false;
|
|
76
|
+
const res = await app().request('/pro');
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns 403 when the user has no active subscription anywhere', async () => {
|
|
81
|
+
const res = await app().request('/pro');
|
|
82
|
+
expect(res.status).toBe(403);
|
|
83
|
+
expect((await res.json()).requiredPlan).toBe('pro');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('grants access via an ORGANIZATION subscription the user belongs to', async () => {
|
|
87
|
+
state.memberships = [{ organization_id: 'org1' }];
|
|
88
|
+
state.orgCustomers = [{ id: 'cust_org1' }];
|
|
89
|
+
state.subscriptions = [{ stripe_price_id: 'price_pro', status: 'active' }];
|
|
90
|
+
const res = await app().request('/pro');
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not grant pro access for an org subscription only at the starter tier', async () => {
|
|
95
|
+
state.memberships = [{ organization_id: 'org1' }];
|
|
96
|
+
state.orgCustomers = [{ id: 'cust_org1' }];
|
|
97
|
+
state.subscriptions = [{ stripe_price_id: 'price_starter', status: 'active' }];
|
|
98
|
+
const res = await app().request('/pro');
|
|
99
|
+
expect(res.status).toBe(403);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { User } from '@supabase/supabase-js';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { HonoVariables } from '@server/types';
|
|
5
|
+
|
|
6
|
+
const { orderMock } = vi.hoisted(() => ({ orderMock: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
9
|
+
supabaseAdmin: {
|
|
10
|
+
from: () => ({ select: () => ({ eq: () => ({ order: orderMock }) }) }),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@server/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
|
|
14
|
+
vi.mock('@server/lib/env', () => ({ env: { SITE_URL: 'https://app.test' } }));
|
|
15
|
+
vi.mock('@server/lib/logger', () => ({
|
|
16
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { adminNewsletterRoutes } = await import('@server/routes/v1/admin/newsletter');
|
|
20
|
+
|
|
21
|
+
function appWithUser(user: Partial<User> | null) {
|
|
22
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
23
|
+
app.use('*', async (c, next) => {
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected user
|
|
25
|
+
c.set('user', (user as any) ?? null);
|
|
26
|
+
await next();
|
|
27
|
+
});
|
|
28
|
+
app.route('/api/v1/admin/newsletter', adminNewsletterRoutes);
|
|
29
|
+
return app;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => orderMock.mockReset());
|
|
33
|
+
|
|
34
|
+
describe('admin newsletter super-admin guard (requireSuperAdmin)', () => {
|
|
35
|
+
it('returns 401 when unauthenticated', async () => {
|
|
36
|
+
const res = await appWithUser(null).request('/api/v1/admin/newsletter/export');
|
|
37
|
+
expect(res.status).toBe(401);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns 403 for a non-super-admin (guard no longer fails closed for admins)', async () => {
|
|
41
|
+
const res = await appWithUser({ id: 'u1', app_metadata: { role: 'authenticated' } }).request(
|
|
42
|
+
'/api/v1/admin/newsletter/export'
|
|
43
|
+
);
|
|
44
|
+
expect(res.status).toBe(403);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('allows a super_admin through', async () => {
|
|
48
|
+
orderMock.mockResolvedValue({ data: [], error: null });
|
|
49
|
+
const res = await appWithUser({ id: 'u1', app_metadata: { role: 'super_admin' } }).request(
|
|
50
|
+
'/api/v1/admin/newsletter/export'
|
|
51
|
+
);
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('newsletter CSV export escaping (formula/CSV injection)', () => {
|
|
57
|
+
it('SECURITY: neutralizes a formula-triggering name and quotes fields', async () => {
|
|
58
|
+
orderMock.mockResolvedValue({
|
|
59
|
+
data: [
|
|
60
|
+
{
|
|
61
|
+
email: 'a@b.com',
|
|
62
|
+
name: '=SUM(1+1)',
|
|
63
|
+
status: 'active',
|
|
64
|
+
subscribed_at: null,
|
|
65
|
+
created_at: '2026-01-01',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
email: 'c@d.com',
|
|
69
|
+
name: 'Ada, "The First"',
|
|
70
|
+
status: 'active',
|
|
71
|
+
subscribed_at: '2026-01-02',
|
|
72
|
+
created_at: '2026-01-03',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
error: null,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const res = await appWithUser({ id: 'u1', app_metadata: { role: 'super_admin' } }).request(
|
|
79
|
+
'/api/v1/admin/newsletter/export'
|
|
80
|
+
);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const csv = await res.text();
|
|
83
|
+
|
|
84
|
+
// Formula neutralized with a leading apostrophe, and quoted.
|
|
85
|
+
expect(csv).toContain('"\'=SUM(1+1)"');
|
|
86
|
+
// No unescaped formula cell (would let a spreadsheet execute it).
|
|
87
|
+
expect(csv).not.toContain(',=SUM(1+1),');
|
|
88
|
+
// Field with comma + embedded quotes is quoted and quotes doubled.
|
|
89
|
+
expect(csv).toContain('"Ada, ""The First"""');
|
|
90
|
+
// Every field is quoted.
|
|
91
|
+
expect(csv).toContain('"a@b.com"');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { HonoVariables } from '@server/types';
|
|
4
|
+
|
|
5
|
+
// adminDb (service role) is used by the handler AFTER the org membership gate.
|
|
6
|
+
// A customer lookup that returns null yields {status:'none'} — enough to prove
|
|
7
|
+
// the request reached the handler (i.e. the gate passed).
|
|
8
|
+
const adminChain = {
|
|
9
|
+
select: () => adminChain,
|
|
10
|
+
eq: () => adminChain,
|
|
11
|
+
is: () => adminChain,
|
|
12
|
+
maybeSingle: async () => ({ data: null, error: null }),
|
|
13
|
+
};
|
|
14
|
+
vi.mock('@server/lib/supabase', () => ({ supabaseAdmin: { from: () => adminChain } }));
|
|
15
|
+
vi.mock('@server/lib/logger', () => ({
|
|
16
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@server/billing', () => ({
|
|
19
|
+
isBillingConfigured: () => true,
|
|
20
|
+
getBillingProvider: () => ({ type: 'stripe' }),
|
|
21
|
+
getProviderPriceIds: () => ({}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { billingRoutes } = await import('@server/routes/v1/billing');
|
|
25
|
+
|
|
26
|
+
// Build an app that injects an authenticated user + a fake RLS-enforced
|
|
27
|
+
// per-user Supabase client whose memberships lookup returns `membershipRole`.
|
|
28
|
+
function appWith(membershipRole: string | null) {
|
|
29
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
30
|
+
app.use('*', async (c, next) => {
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected user
|
|
32
|
+
c.set('user', { id: 'u1', email: 'u@test.com' } as any);
|
|
33
|
+
c.set('aal', 'aal2');
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal supabase double
|
|
35
|
+
c.set('supabase', {
|
|
36
|
+
from: () => ({
|
|
37
|
+
select: () => ({
|
|
38
|
+
eq: () => ({
|
|
39
|
+
eq: () => ({
|
|
40
|
+
single: async () => ({
|
|
41
|
+
data: membershipRole ? { role: membershipRole } : null,
|
|
42
|
+
error: membershipRole ? null : { message: 'no rows' },
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
} as any);
|
|
49
|
+
await next();
|
|
50
|
+
});
|
|
51
|
+
app.route('/api/v1/billing', billingRoutes);
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ORG = '00000000-0000-0000-0000-0000000000aa';
|
|
56
|
+
|
|
57
|
+
describe('GET /billing/subscription org access control (IDOR fix)', () => {
|
|
58
|
+
it('SECURITY: returns 403 when the caller is not a member of the target org', async () => {
|
|
59
|
+
const res = await appWith(null).request(
|
|
60
|
+
`/api/v1/billing/subscription?type=organization&organizationId=${ORG}`
|
|
61
|
+
);
|
|
62
|
+
expect(res.status).toBe(403);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('allows an OWNER of the target org through the gate', async () => {
|
|
66
|
+
const res = await appWith('OWNER').request(
|
|
67
|
+
`/api/v1/billing/subscription?type=organization&organizationId=${ORG}`
|
|
68
|
+
);
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
expect((await res.json()).status).toBe('none');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects a non-privileged member (e.g. MEMBER role)', async () => {
|
|
74
|
+
const res = await appWith('MEMBER').request(
|
|
75
|
+
`/api/v1/billing/subscription?type=organization&organizationId=${ORG}`
|
|
76
|
+
);
|
|
77
|
+
expect(res.status).toBe(403);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('requires an organizationId for organization-scoped requests', async () => {
|
|
81
|
+
const res = await appWith('OWNER').request(
|
|
82
|
+
'/api/v1/billing/subscription?type=organization'
|
|
83
|
+
);
|
|
84
|
+
expect(res.status).toBe(400);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('is inert for user-scoped requests (no org membership needed)', async () => {
|
|
88
|
+
const res = await appWith(null).request('/api/v1/billing/subscription?type=user');
|
|
89
|
+
expect(res.status).toBe(200);
|
|
90
|
+
expect((await res.json()).status).toBe('none');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mountRoute } from '../../../_helpers/app';
|
|
3
|
+
|
|
4
|
+
const { maybeSingleMock, fromSpy } = vi.hoisted(() => ({
|
|
5
|
+
maybeSingleMock: vi.fn(),
|
|
6
|
+
fromSpy: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
10
|
+
supabaseAdmin: {
|
|
11
|
+
from: (...args: unknown[]) => {
|
|
12
|
+
fromSpy(...args);
|
|
13
|
+
return {
|
|
14
|
+
update: () => ({
|
|
15
|
+
eq: () => ({
|
|
16
|
+
eq: () => ({ select: () => ({ maybeSingle: maybeSingleMock }) }),
|
|
17
|
+
}),
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@server/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
|
|
24
|
+
vi.mock('@server/lib/env', () => ({ env: { SITE_URL: 'https://app.test' } }));
|
|
25
|
+
vi.mock('@server/lib/logger', () => ({
|
|
26
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('@server/lib/rate-limiter', () => ({
|
|
29
|
+
createRateLimiter: () => async (_c: unknown, next: () => Promise<void>) => {
|
|
30
|
+
await next();
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const { newsletterRoutes } = await import('@server/routes/v1/newsletter');
|
|
35
|
+
const app = mountRoute('/api/v1/newsletter', newsletterRoutes);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
maybeSingleMock.mockReset();
|
|
39
|
+
fromSpy.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('GET /newsletter/unsubscribe token requirement (finding 6)', () => {
|
|
43
|
+
it('SECURITY: rejects an unsubscribe with only an email (no token) and never touches the DB', async () => {
|
|
44
|
+
const res = await app.request('/api/v1/newsletter/unsubscribe?email=victim@example.com');
|
|
45
|
+
expect(res.status).toBe(400);
|
|
46
|
+
expect(fromSpy).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('unsubscribes when email + matching confirmation_token are supplied', async () => {
|
|
50
|
+
maybeSingleMock.mockResolvedValue({ data: { id: 'sub-1' }, error: null });
|
|
51
|
+
const res = await app.request(
|
|
52
|
+
'/api/v1/newsletter/unsubscribe?email=user@example.com&token=good-token'
|
|
53
|
+
);
|
|
54
|
+
expect(res.status).toBe(302);
|
|
55
|
+
expect(res.headers.get('location')).toContain('newsletter=unsubscribed');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('SECURITY: rejects a non-matching token (no row updated)', async () => {
|
|
59
|
+
maybeSingleMock.mockResolvedValue({ data: null, error: null });
|
|
60
|
+
const res = await app.request(
|
|
61
|
+
'/api/v1/newsletter/unsubscribe?email=user@example.com&token=wrong-token'
|
|
62
|
+
);
|
|
63
|
+
expect(res.status).toBe(400);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { User } from '@supabase/supabase-js';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { jsonPost } from '../../../_helpers/app';
|
|
5
|
+
import type { HonoVariables } from '@server/types';
|
|
6
|
+
|
|
7
|
+
vi.mock('@server/lib/supabase', () => ({
|
|
8
|
+
supabaseAdmin: { from: () => ({ upsert: async () => ({ error: null }) }) },
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@server/lib/logger', () => ({
|
|
11
|
+
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { themeRoutes } = await import('@server/routes/v1/theme');
|
|
15
|
+
|
|
16
|
+
function adminApp() {
|
|
17
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
18
|
+
app.use('*', async (c, next) => {
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal injected super admin
|
|
20
|
+
c.set('user', { id: 'admin1', app_metadata: { role: 'super_admin' } } as Partial<User> as any);
|
|
21
|
+
await next();
|
|
22
|
+
});
|
|
23
|
+
app.route('/api/v1/admin/theme', themeRoutes);
|
|
24
|
+
return app;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function patch(theme: unknown) {
|
|
28
|
+
return adminApp().request('/api/v1/admin/theme', {
|
|
29
|
+
...jsonPost(theme),
|
|
30
|
+
method: 'PATCH',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('PATCH /admin/theme color/radius validation (stored XSS / CSS injection)', () => {
|
|
35
|
+
it('accepts valid oklch / hex colors and a unit radius', async () => {
|
|
36
|
+
const res = await patch({
|
|
37
|
+
light: { primary: 'oklch(0.52 0.124 192)', destructive: '#ff0000' },
|
|
38
|
+
dark: { primary: 'oklch(0.82 0.14 192)' },
|
|
39
|
+
radius: '0.625rem',
|
|
40
|
+
});
|
|
41
|
+
expect(res.status).toBe(200);
|
|
42
|
+
expect((await res.json()).success).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('SECURITY: rejects a CSS breakout payload in a color field', async () => {
|
|
46
|
+
const res = await patch({ light: { primary: 'red; } body { background: url(//evil) } .x {' } });
|
|
47
|
+
expect(res.status).toBe(400);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('SECURITY: rejects a non-color string (e.g. url / expression)', async () => {
|
|
51
|
+
const res = await patch({ light: { warning: 'url(javascript:alert(1))' } });
|
|
52
|
+
expect(res.status).toBe(400);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('SECURITY: rejects a radius that smuggles extra declarations', async () => {
|
|
56
|
+
const res = await patch({ radius: '1rem; } :root { --primary: red' });
|
|
57
|
+
expect(res.status).toBe(400);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('accepts rgb()/hsl() color forms', async () => {
|
|
61
|
+
const res = await patch({
|
|
62
|
+
light: { primary: 'rgb(12, 34, 56)', secondaryAccent: 'hsl(210, 50%, 40%)' },
|
|
63
|
+
});
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
});
|
|
66
|
+
});
|