vibecarbon 0.9.0 → 0.11.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.
Files changed (88) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +968 -886
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. 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
- // Zod schema for theme validation all fields optional
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: z.string().optional(),
13
- primaryDim: z.string().optional(),
14
- primaryForeground: z.string().optional(),
15
- secondaryAccent: z.string().optional(),
16
- secondaryAccentForeground: z.string().optional(),
17
- destructive: z.string().optional(),
18
- warning: z.string().optional(),
19
- success: z.string().optional(),
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: z.string().optional(),
27
- primaryDim: z.string().optional(),
28
- primaryForeground: z.string().optional(),
29
- secondaryAccent: z.string().optional(),
30
- secondaryAccentForeground: z.string().optional(),
47
+ primary: colorField,
48
+ primaryDim: colorField,
49
+ primaryForeground: colorField,
50
+ secondaryAccent: colorField,
51
+ secondaryAccentForeground: colorField,
31
52
  })
32
53
  .optional(),
33
- radius: z.string().optional(),
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 created_at < now() - interval '24 hours';
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 expires_at IS NOT NULL AND expires_at < now();
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
- -- Allow anonymous inserts (public contact form)
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
- -- Allow anonymous inserts (public signup form)
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
+ });