Add secret to electric url

This commit is contained in:
Nate Kelley 2025-06-11 15:56:14 -06:00
parent d93e51cb61
commit eb288b1282
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
11 changed files with 338 additions and 261 deletions

View File

@ -12,6 +12,7 @@ SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9
POSTHOG_TELEMETRY_KEY="phc_zZraCicSTfeXX5b9wWQv2rWG8QB4Z3xlotOT7gFtoNi"
TELEMETRY_ENABLED="true"
MAX_RECURSION="15"
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
# AI VARS
RERANK_API_KEY="your_rerank_api_key"
@ -27,3 +28,12 @@ NEXT_PUBLIC_SUPABASE_URL="http://kong:8000" # External URL for Supabase (Kong pr
NEXT_PUBLIC_WS_URL="ws://localhost:3001"
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
NEXT_PRIVATE_SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
# TS SERVER
SERVER_PORT=3002
# ELECTRIC
ELECTRIC_PROXY_URL=http://localhost:3003
ELECTRIC_PORT=3003
ELECTRIC_INSECURE=false
ELECTRIC_SECRET=my-little-buttercup-has-the-sweetest-smile

View File

@ -2,7 +2,10 @@ services:
electric:
image: electricsql/electric
ports:
- "3003:3000" # Expose Electric's HTTP API on port 3011 instead of 3000
- "3003:3003" # Expose Electric's HTTP API on port 3003 instead of 3000
environment:
DATABASE_URL: "postgresql://postgres:postgres@host.docker.internal:54322/postgres?sslmode=disable"
ELECTRIC_INSECURE: true
ELECTRIC_INSECURE: ${ELECTRIC_INSECURE:-false}
ELECTRIC_PROXY_URL: ${ELECTRIC_PROXY_URL:-http://localhost:3003}
ELECTRIC_PORT: ${ELECTRIC_PORT:-3003}
ELECTRIC_SECRET: ${ELECTRIC_SECRET:-my-little-buttercup-has-the-sweetest-smile}

View File

@ -1,3 +1,6 @@
include ../.env
export
dev:
docker compose stop && docker compose up -d

View File

@ -1,4 +1,4 @@
PORT=3002
SUPABASE_URL="http://127.0.0.1:54321"
SUPABASE_SERVICE_ROLE_KEY=""
ELECTRIC_URL="http://localhost:3003"
ELECTRIC_PROXY_URL="http://localhost:3003"

View File

@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProxiedResponse } from './electricHandler';
describe('createProxiedResponse', () => {
const mockFetch = vi.fn<typeof fetch>();
let originalEnv: string | undefined;
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
vi.clearAllMocks();
// Store original environment variable
originalEnv = process.env.ELECTRIC_SECRET;
// Set default secret for tests
process.env.ELECTRIC_SECRET = 'test-secret';
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original environment variable
if (originalEnv !== undefined) {
process.env.ELECTRIC_SECRET = originalEnv;
} else {
process.env.ELECTRIC_SECRET = undefined;
}
});
it('should proxy a successful response and remove content-encoding and content-length headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockResponseBody = 'test response body';
// Create mock headers that include content-encoding and content-length
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '123',
'cache-control': 'no-cache',
'custom-header': 'custom-value',
});
const mockResponse = new Response(mockResponseBody, {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify fetch was called with correct URL
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(testUrl);
// Verify response properties
expect(await result.text()).toBe(mockResponseBody);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
// Verify headers were properly modified
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
// Verify other headers are preserved
expect(result.headers.get('content-type')).toBe('application/json');
expect(result.headers.get('cache-control')).toBe('no-cache');
expect(result.headers.get('custom-header')).toBe('custom-value');
});
it('should handle responses without content-encoding or content-length headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockResponseBody = 'test response body';
const mockHeaders = new Headers({
'content-type': 'text/plain',
'cache-control': 'max-age=3600',
});
const mockResponse = new Response(mockResponseBody, {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify response properties
expect(await result.text()).toBe(mockResponseBody);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
// Verify headers are preserved (nothing to remove)
expect(result.headers.get('content-type')).toBe('text/plain');
expect(result.headers.get('cache-control')).toBe('max-age=3600');
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
});
it('should proxy error responses correctly', async () => {
const testUrl = new URL('https://example.com/error');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'deflate',
'content-length': '456',
});
const mockResponse = new Response('{"error": "Not found"}', {
status: 404,
statusText: 'Not Found',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify error response is properly proxied
expect(result.status).toBe(404);
expect(result.statusText).toBe('Not Found');
expect(await result.text()).toBe('{"error": "Not found"}');
// Verify headers are still properly cleaned
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
expect(result.headers.get('content-type')).toBe('application/json');
});
it('should handle responses with only content-encoding header', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'br',
});
const mockResponse = new Response('compressed data', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify only content-encoding is removed
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false); // Should be false (wasn't present)
expect(result.headers.get('content-type')).toBe('application/json');
});
it('should handle responses with only content-length header', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'text/html',
'content-length': '789',
});
const mockResponse = new Response('<html></html>', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify only content-length is removed
expect(result.headers.has('content-length')).toBe(false);
expect(result.headers.has('content-encoding')).toBe(false); // Should be false (wasn't present)
expect(result.headers.get('content-type')).toBe('text/html');
});
it('should preserve all other headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '100',
authorization: 'Bearer token123',
'x-custom-header': 'custom-value',
'cache-control': 'private, max-age=0',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
const mockResponse = new Response('response data', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify the problematic headers are removed
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
// Verify all other headers are preserved
expect(result.headers.get('content-type')).toBe('application/json');
expect(result.headers.get('authorization')).toBe('Bearer token123');
expect(result.headers.get('x-custom-header')).toBe('custom-value');
expect(result.headers.get('cache-control')).toBe('private, max-age=0');
expect(result.headers.get('etag')).toBe('"abc123"');
expect(result.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
});
it('should handle fetch errors', async () => {
// Set a valid secret key first
process.env.ELECTRIC_SECRET = 'test-secret';
const testUrl = new URL('https://example.com/error');
const fetchError = new Error('Network error');
mockFetch.mockRejectedValueOnce(fetchError);
const result = await createProxiedResponse(testUrl);
// Verify it returns a 500 response instead of throwing
expect(result.status).toBe(500);
expect(await result.text()).toBe('Internal Server Error');
expect(mockFetch).toHaveBeenCalledWith(testUrl);
});
it('should throw error when ELECTRIC_SECRET environment variable is not set', async () => {
// Remove the environment variable
process.env.ELECTRIC_SECRET = '';
const testUrl = new URL('https://example.com/test');
await expect(createProxiedResponse(testUrl)).rejects.toThrow('ELECTRIC_SECRET is not set');
// Verify fetch was never called since error is thrown before
expect(mockFetch).not.toHaveBeenCalled();
});
it('should add secret key to URL params when ELECTRIC_SECRET is set', async () => {
const secretKey = 'test-secret-key-123';
process.env.ELECTRIC_SECRET = secretKey;
const testUrl = new URL('https://example.com/test');
const mockResponseBody = 'test response';
const mockResponse = new Response(mockResponseBody, {
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify fetch was called with URL that has secret parameter
expect(mockFetch).toHaveBeenCalledOnce();
const calledUrl = mockFetch.mock.calls[0][0] as URL;
expect(calledUrl.searchParams.get('secret')).toBe(secretKey);
// Verify the response is still valid
expect(await result.text()).toBe(mockResponseBody);
expect(result.status).toBe(200);
});
});

View File

@ -0,0 +1,29 @@
export const createProxiedResponse = async (url: URL) => {
const secretKey = process.env.ELECTRIC_SECRET;
if (!secretKey) {
throw new Error('ELECTRIC_SECRET is not set');
}
url.searchParams.set('secret', secretKey);
const response = await fetch(url).catch((error) => {
console.error('Error fetching from Electric:', error);
return new Response('Internal Server Error', { status: 500 });
});
// Fetch decompresses the body but doesn't remove the
// content-encoding & content-length headers which would
// break decoding in the browser.
// See https://github.com/whatwg/fetch/issues/1729
const headers = new Headers(response.headers);
headers.delete('content-encoding');
headers.delete('content-length');
// Return the proxied response
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};

View File

@ -1,33 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProxiedResponse, extractParamFromWhere, getElectricShapeUrl } from './_helpers';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { extractParamFromWhere, getElectricShapeUrl } from '.';
describe('getElectricShapeUrl', () => {
process.env.ELECTRIC_URL = 'http://localhost:3000';
const originalElectricUrl = process.env.ELECTRIC_URL;
process.env.ELECTRIC_PROXY_URL = 'http://localhost:3000';
const originalElectricUrl = process.env.ELECTRIC_PROXY_URL;
beforeEach(() => {
// Clean up environment variable before each test
process.env.ELECTRIC_URL = undefined;
process.env.ELECTRIC_PROXY_URL = undefined;
});
afterEach(() => {
// Restore original environment variable after each test
if (originalElectricUrl !== undefined) {
process.env.ELECTRIC_URL = originalElectricUrl;
process.env.ELECTRIC_PROXY_URL = originalElectricUrl;
} else {
process.env.ELECTRIC_URL = undefined;
process.env.ELECTRIC_PROXY_URL = undefined;
}
});
it('should return default URL with /v1/shape path when no ELECTRIC_URL is set', () => {
it('should return default URL with /v1/shape path when no ELECTRIC_PROXY_URL is set', () => {
const requestUrl = 'http://example.com/test?table=users';
const result = getElectricShapeUrl(requestUrl);
expect(result.toString()).toBe('http://localhost:3000/v1/shape?table=users');
});
it('should use ELECTRIC_URL environment variable when set', () => {
process.env.ELECTRIC_URL = 'https://electric.example.com';
it('should use ELECTRIC_PROXY_URL environment variable when set', () => {
process.env.ELECTRIC_PROXY_URL = 'https://electric.example.com';
const requestUrl = 'http://example.com/test?table=users&live=true';
const result = getElectricShapeUrl(requestUrl);
@ -138,16 +138,16 @@ describe('getElectricShapeUrl', () => {
expect(result.searchParams.get('table')).toBe('posts');
});
it('should handle ELECTRIC_URL with trailing slash', () => {
process.env.ELECTRIC_URL = 'https://electric.example.com/';
it('should handle ELECTRIC_PROXY_URL with trailing slash', () => {
process.env.ELECTRIC_PROXY_URL = 'https://electric.example.com/';
const requestUrl = 'http://example.com/test?table=users';
const result = getElectricShapeUrl(requestUrl);
expect(result.toString()).toBe('https://electric.example.com/v1/shape?table=users');
});
it('should handle ELECTRIC_URL with path', () => {
process.env.ELECTRIC_URL = 'https://api.example.com/electric';
it('should handle ELECTRIC_PROXY_URL with path', () => {
process.env.ELECTRIC_PROXY_URL = 'https://api.example.com/electric';
const requestUrl = 'http://example.com/test?table=users';
const result = getElectricShapeUrl(requestUrl);
@ -155,219 +155,8 @@ describe('getElectricShapeUrl', () => {
});
});
describe('createProxiedResponse', () => {
const mockFetch = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should proxy a successful response and remove content-encoding and content-length headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockResponseBody = 'test response body';
// Create mock headers that include content-encoding and content-length
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '123',
'cache-control': 'no-cache',
'custom-header': 'custom-value',
});
const mockResponse = new Response(mockResponseBody, {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify fetch was called with correct URL
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(testUrl);
// Verify response properties
expect(await result.text()).toBe(mockResponseBody);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
// Verify headers were properly modified
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
// Verify other headers are preserved
expect(result.headers.get('content-type')).toBe('application/json');
expect(result.headers.get('cache-control')).toBe('no-cache');
expect(result.headers.get('custom-header')).toBe('custom-value');
});
it('should handle responses without content-encoding or content-length headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockResponseBody = 'test response body';
const mockHeaders = new Headers({
'content-type': 'text/plain',
'cache-control': 'max-age=3600',
});
const mockResponse = new Response(mockResponseBody, {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify response properties
expect(await result.text()).toBe(mockResponseBody);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
// Verify headers are preserved (nothing to remove)
expect(result.headers.get('content-type')).toBe('text/plain');
expect(result.headers.get('cache-control')).toBe('max-age=3600');
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
});
it('should proxy error responses correctly', async () => {
const testUrl = new URL('https://example.com/error');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'deflate',
'content-length': '456',
});
const mockResponse = new Response('{"error": "Not found"}', {
status: 404,
statusText: 'Not Found',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify error response is properly proxied
expect(result.status).toBe(404);
expect(result.statusText).toBe('Not Found');
expect(await result.text()).toBe('{"error": "Not found"}');
// Verify headers are still properly cleaned
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
expect(result.headers.get('content-type')).toBe('application/json');
});
it('should handle responses with only content-encoding header', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'br',
});
const mockResponse = new Response('compressed data', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify only content-encoding is removed
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false); // Should be false (wasn't present)
expect(result.headers.get('content-type')).toBe('application/json');
});
it('should handle responses with only content-length header', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'text/html',
'content-length': '789',
});
const mockResponse = new Response('<html></html>', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify only content-length is removed
expect(result.headers.has('content-length')).toBe(false);
expect(result.headers.has('content-encoding')).toBe(false); // Should be false (wasn't present)
expect(result.headers.get('content-type')).toBe('text/html');
});
it('should preserve all other headers', async () => {
const testUrl = new URL('https://example.com/test');
const mockHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '100',
authorization: 'Bearer token123',
'x-custom-header': 'custom-value',
'cache-control': 'private, max-age=0',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
const mockResponse = new Response('response data', {
status: 200,
statusText: 'OK',
headers: mockHeaders,
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await createProxiedResponse(testUrl);
// Verify the problematic headers are removed
expect(result.headers.has('content-encoding')).toBe(false);
expect(result.headers.has('content-length')).toBe(false);
// Verify all other headers are preserved
expect(result.headers.get('content-type')).toBe('application/json');
expect(result.headers.get('authorization')).toBe('Bearer token123');
expect(result.headers.get('x-custom-header')).toBe('custom-value');
expect(result.headers.get('cache-control')).toBe('private, max-age=0');
expect(result.headers.get('etag')).toBe('"abc123"');
expect(result.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
});
it('should handle fetch errors', async () => {
const testUrl = new URL('https://example.com/error');
const fetchError = new Error('Network error');
mockFetch.mockRejectedValueOnce(fetchError);
await expect(createProxiedResponse(testUrl)).rejects.toThrow('Network error');
expect(mockFetch).toHaveBeenCalledWith(testUrl);
});
});
describe('extractParamFromWhere', () => {
it('should', () => {
it('should handle a simple where clause', () => {
const testClause = "where=id='420226c8-b91d-49c5-99f8-660b04cc8c01'&offset=-1";
const url = new URL(`https://example.com/test?${testClause}`);
const result = extractParamFromWhere(url, 'id');

View File

@ -1,10 +1,10 @@
export const getElectricShapeUrl = (requestUrl: string) => {
const url = new URL(requestUrl);
const electricUrl = process.env.ELECTRIC_URL;
const electricUrl = process.env.ELECTRIC_PROXY_URL;
if (!electricUrl) {
throw new Error('ELECTRIC_URL is not set');
throw new Error('ELECTRIC_PROXY_URL is not set');
}
const baseUrl =
@ -39,32 +39,6 @@ export const getElectricShapeUrl = (requestUrl: string) => {
return originUrl;
};
export const createProxiedResponse = async (url: URL) => {
const response = await fetch(url);
// Fetch decompresses the body but doesn't remove the
// content-encoding & content-length headers which would
// break decoding in the browser.
// See https://github.com/whatwg/fetch/issues/1729
const headers = new Headers(response.headers);
headers.delete('content-encoding');
headers.delete('content-length');
// Return the proxied response
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
/**
* Extracts a parameter value from the where clause in the URL
* @param url - The URL object containing the where parameter
* @param paramName - The parameter name to extract (e.g., 'chatId', 'userId')
* @returns The parameter value or null if not found
*/
export const extractParamFromWhere = (url: URL, paramName: string): string | null => {
const whereClause = url.searchParams.get('where');

View File

@ -0,0 +1,2 @@
export * from './helpers';
export * from './electricHandler';

View File

@ -8,9 +8,6 @@ export const requireAuth = bearerAuth({
verifyToken: async (token, c) => {
const { data, error } = await supabase.auth.getUser(token); //usually takes about 3 - 7ms
if (token.includes('Cj0g')) {
}
if (error || !data.user) {
return false;
}

View File

@ -50,7 +50,7 @@ NODE_ENV=production
SERVER_PORT=3002
SUPABASE_URL=http://localhost:54321
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
ELECTRIC_URL=http://localhost:3003
ELECTRIC_PROXY_URL=http://localhost:3003
DATABASE_URL=http://localhost:54321
EOF