Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
**/node_modules
**/dist
.git
.env
.env.*
**/.env
**/.env.*
**/* 2.*
contracts/target
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ VITE_USE_MOCKS=false

# --- Keeper backend ---
# testnet = vrai vault Soroban sur Stellar Testnet (transactions réelles) ; fake = vault en mémoire, hashes factices.
# KEEPER_ALLOWED_ORIGINS : origines web supplémentaires autorisées (CORS), séparées par des virgules ; localhost est toujours autorisé.
# KEEPER_ALLOWED_ORIGINS : origines web supplémentaires autorisées (CORS), séparées par des virgules ; localhost est autorisé en local seulement : un keeper hébergé le refuse.
KEEPER_BACKEND=
KEEPER_ALLOWED_ORIGINS=

# --- Hosted keeper (Render ou Railway) : voir docs/deploy.md ---
# KEEPER_HOST=0.0.0.0 # défaut 127.0.0.1. Hors loopback, le keeper est public : les 4 variables ci-dessous deviennent obligatoires.
# KEEPER_ACCESS_TOKENS= # codes d'accès des routes POST, séparés par des virgules, 32 caractères minimum chacun (openssl rand -hex 24). Jamais dans Vercel.
# (sur un keeper hébergé, KEEPER_ALLOWED_ORIGINS ci-dessus liste les origines HTTPS exactes du site ; * remplace un seul segment de nom)
# KEEPER_PUBLIC_HOSTS= # nom d'hôte public du keeper, sans schéma (xxx.onrender.com ou xxx.up.railway.app)
# KEEPER_DATA_DIR=/data # dossier durable (volume) : session, caches Euribor et marché
# KEEPER_TRUST_PROXY_HOPS=1 # nombre de proxys devant le keeper
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ keeper/data/market-cache.local.json
Thumbs.db
.vscode/
.idea/

# Keeper runtime state (hosted or local run)
keeper/data/session.json*
keeper/data/*.probe
27 changes: 27 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Keystone keeper (Fastify + SSE) for a Node host such as Railway. The web app is built and served by Vercel.
FROM node:24-slim
WORKDIR /app

# Manifests first: the dependency layer is reused until a package.json or the lockfile changes.
COPY package.json package-lock.json ./
COPY app/package.json app/
COPY keeper/package.json keeper/
COPY packages/shared/package.json packages/shared/
COPY packages/vault-client/package.json packages/vault-client/
COPY scripts/package.json scripts/
RUN npm ci --workspace=@keystone/keeper --workspace=vault-client --workspace=@keystone/shared --include-workspace-root

COPY packages packages
COPY keeper keeper
COPY deployments deployments
# The generated contract bindings are not versioned: compile them into packages/vault-client/dist.
RUN npm run build -w vault-client

# Hosted defaults; the host sets PORT, KEEPER_SECRET, KEEPER_ACCESS_TOKENS and the origins (see README).
ENV NODE_ENV=production \
KEEPER_HOST=0.0.0.0 \
KEEPER_BACKEND=testnet \
SIGNER=freighter \
KEEPER_DATA_DIR=/data
EXPOSE 8787
CMD ["node_modules/.bin/tsx", "keeper/src/index.ts"]
112 changes: 86 additions & 26 deletions README.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { AssetId, KeystoneConfig, VaultStatus } from '@keystone/shared'
import { LockScreen } from './auth/LockScreen.tsx'
import { currentUser, rememberUser } from './auth/session.ts'
import { KEEPER_URL } from './config.ts'
import { AccessCodeModal } from './dashboard/AccessCodeModal.tsx'
import { ActionsCard } from './dashboard/ActionsCard.tsx'
import { ActivityFeed } from './dashboard/ActivityFeed.tsx'
import { assetOf } from './dashboard/assets.ts'
import { DetailsModal } from './dashboard/DetailsModal.tsx'
import { Landing } from './dashboard/Landing.tsx'
import { MarketAdvisor } from './dashboard/MarketAdvisor.tsx'
import { MarketCard } from './dashboard/MarketCard.tsx'
import { StatusCard } from './dashboard/StatusCard.tsx'
import { VaultCard } from './dashboard/VaultCard.tsx'
Expand Down Expand Up @@ -131,9 +133,15 @@ function Main({ user, onLock }: { user: string; onLock(): void }) {
const mocks = k.source === 'mocks'
const asset = k.config && k.session ? assetOf(k.config, k.session) : null
const [details, setDetails] = useState(false)
// Asked as soon as the keeper says it needs a code; "Not now" keeps the page readable until an action is refused.
const [codeDismissed, setCodeDismissed] = useState(false)
useEffect(() => {
if (k.error?.code === 'Unauthorized') setCodeDismissed(false)
}, [k.error])

return (
<>
<AccessCodeModal open={k.needsAccessCode && !codeDismissed} onSave={k.saveAccessCode} onClose={() => setCodeDismissed(true)} />
<TopBar
k={k}
mocks={mocks}
Expand Down Expand Up @@ -237,6 +245,8 @@ function TopBar({
<Wordmark />
</h1>
{mocks ? <Chip>Demo data · nothing sent to Stellar</Chip> : <Chip>Stellar Testnet</Chip>}
{/* A keeper on the in-memory vault says so: its hashes are not Testnet transactions. */}
{!mocks && k.config?.backend === 'fake' && <Chip>Demo backend · fake transactions</Chip>}
{/* §4: the public demo (SIGNER=keeper) must say that the keeper signs for the fund. */}
{k.config?.signerMode === 'keeper' && <Chip>Public demo · keeper signs</Chip>}
</div>
Expand All @@ -262,6 +272,8 @@ function TopBar({
: 'Reconnecting…'}
</span>
</span>
{/* The advisor is read-only: with a session running it stays one click away (it never starts or changes anything). */}
{k.session && <MarketAdvisor className="!h-9 !px-4" label="Market" closeLabel="Close" inSession />}
{onDetails && (
<Button variant="sim" className="!h-9 !px-4" onClick={onDetails}>
Details
Expand Down
22 changes: 22 additions & 0 deletions app/src/api/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The team access code that authorizes writes on a hosted keeper. It is typed by the person, kept in
// this browser only, and sent as `Authorization: Bearer`. It is never part of the build.
const KEY = 'keystone.accessCode'
let memory: string | null = null

export function getAccessCode(): string | null {
try {
return localStorage.getItem(KEY) ?? memory
} catch {
return memory
}
}

export function setAccessCode(code: string | null): void {
memory = code
try {
if (code) localStorage.setItem(KEY, code)
else localStorage.removeItem(KEY)
} catch {
// Storage blocked: the code then lives until the tab is closed.
}
}
1 change: 1 addition & 0 deletions app/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const MESSAGES: Record<string, string> = {
Defaulted: 'The vault has defaulted. Only the available collateral can still be withdrawn.',
// Keeper (section 4)
BadRequest: 'The keeper rejected the request.',
Unauthorized: 'The access code is missing or wrong. Enter the team access code to act on the keeper.',
NoSession: 'No session is running. Start one first.',
SignerModeDisabled: 'This action is not available in the current signing mode.',
TxFailed: 'The transaction failed.',
Expand Down
7 changes: 6 additions & 1 deletion app/src/api/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ActivityEvent, ApiError, AssetId, KeystoneConfig, MarketSnapshot, SessionState } from '@keystone/shared'
import type { KeeperClient, StreamListener } from './client.ts'
import { getAccessCode } from './access.ts'
import { KeystoneError } from './errors.ts'

/** Advisor deliberately uses only this GET, even while session actions run in mock mode. */
Expand Down Expand Up @@ -39,9 +40,13 @@ export function createHttpClient(baseUrl: string): KeeperClient {
async function request<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
let response: Response
try {
const code = method === 'POST' ? getAccessCode() : null
response = await fetch(`${baseUrl}${path}`, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(code ? { authorization: `Bearer ${code}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch {
Expand Down
11 changes: 9 additions & 2 deletions app/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ export const KEEPER_URL: string = (
import.meta.env.VITE_KEEPER_URL || 'http://localhost:8787/api'
).replace(/\/+$/, '')

/** Exactly "true" turns the in-browser mock keeper on. */
export const USE_MOCKS: boolean = import.meta.env.VITE_USE_MOCKS === 'true'
/**
* `?demo` in the page address plays the whole flow on the in-browser mock, on any build: a visitor who has no access
* code (or no Freighter fund account) can still explore. The page says "Demo data · nothing sent to Stellar".
*/
export const DEMO_REQUESTED: boolean =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('demo')

/** Exactly "true" turns the in-browser mock keeper on, as does `?demo`. */
export const USE_MOCKS: boolean = import.meta.env.VITE_USE_MOCKS === 'true' || DEMO_REQUESTED

/** Mocks only: "keeper" plays the SIGNER=keeper fallback, anything else the Freighter flow. */
export const MOCK_SIGNER_MODE: 'freighter' | 'keeper' =
Expand Down
47 changes: 47 additions & 0 deletions app/src/dashboard/AccessCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useState } from 'react'
import { Button } from '../ui/index.ts'
import { Modal } from '../ui/Modal.tsx'

/** Asks for the team access code that a hosted keeper requires before it deploys, posts or signs anything. */
export function AccessCodeModal({ open, onSave, onClose }: { open: boolean; onSave(code: string): void; onClose(): void }) {
const [code, setCode] = useState('')
return (
<Modal open={open} title="Team access code" onClose={onClose}>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (code.trim()) onSave(code)
setCode('')
}}
>
<p className="text-sm text-ink-muted">
This keeper acts on Stellar Testnet for the team. Enter the access code you were given to start sessions and move
collateral. Reading the dashboard needs no code.
</p>
<input
type="password"
autoComplete="off"
spellCheck={false}
aria-label="Access code"
placeholder="Access code"
value={code}
onChange={(event) => setCode(event.target.value)}
className="glass h-12 w-full rounded-full px-5 text-sm text-ink outline-none placeholder:text-ink-subtle focus-visible:outline-2 focus-visible:outline-chain"
/>
<p className="text-xs text-ink-subtle">
No code? <a className="underline underline-offset-2" href="?demo">Explore the simulated demo</a>: the full flow on
simulated data, nothing is sent to Stellar.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Not now
</Button>
<Button type="submit" variant="primary" disabled={!code.trim()}>
Unlock actions
</Button>
</div>
</form>
</Modal>
)
}
18 changes: 14 additions & 4 deletions app/src/dashboard/MarketAdvisor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ function Source({ metric }: { metric: MarketMetric }) {
}

/** Pure display: choosing an asset and starting a vault remain separate, explicit Landing actions. */
export function MarketAdvisor({ className }: { className?: string }) {
export function MarketAdvisor({ className, label = 'Compare market conditions', closeLabel = 'Back to manual selection', inSession = false }: {
className?: string
label?: string
closeLabel?: string
/** Opened from the dashboard (a session is running): there is no selection or Start button to mention. */
inSession?: boolean
}) {
const [open, setOpen] = useState(false)
const [attempt, setAttempt] = useState(0)
const [snapshot, setSnapshot] = useState<MarketSnapshot | null>(null)
Expand All @@ -36,7 +42,7 @@ export function MarketAdvisor({ className }: { className?: string }) {
}, [open, attempt])

return <>
<Button className={className ?? 'mt-5 w-full'} onClick={() => setOpen(true)}>Compare market conditions</Button>
<Button className={className ?? 'mt-5 w-full'} aria-label="Open the collateral market advisor" onClick={() => setOpen(true)}>{label}</Button>
<Modal open={open} title="Collateral Market Advisor" onClose={() => setOpen(false)}>
<p className="text-sm text-ink-muted">Indicative class comparison. Real published market observations, separate from simulated token prices. No wallet needed.</p>
{pending && <p className="mt-4 text-sm" role="status">Loading market observations…</p>}
Expand Down Expand Up @@ -74,9 +80,13 @@ export function MarketAdvisor({ className }: { className?: string }) {
</div>}
<div className="mt-5 flex flex-wrap gap-2">
<Button busy={pending} disabled={pending} onClick={() => setAttempt((v) => v + 1)}>Refresh observations</Button>
<Button variant="primary" onClick={() => setOpen(false)}>Back to manual selection</Button>
<Button variant="primary" onClick={() => setOpen(false)}>{closeLabel}</Button>
</div>
<p className="mt-3 text-xs text-ink-subtle">Closing this assistant leaves your selection unchanged. Starting a session requires the separate Start button.</p>
<p className="mt-3 text-xs text-ink-subtle">
{inSession
? 'Closing this assistant changes nothing: it is read-only and never touches the running session.'
: 'Closing this assistant leaves your selection unchanged. Starting a session requires the separate Start button.'}
</p>
</Modal>
</>
}
16 changes: 16 additions & 0 deletions app/src/state/useKeystone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ActivityEvent, AssetId, KeystoneConfig, SessionState } from '@keystone/shared'
import { createBackend, type Backend } from '../api/index.ts'
import type { ConnectionStatus } from '../api/client.ts'
import { getAccessCode, setAccessCode } from '../api/access.ts'
import { describeError, KeystoneError } from '../api/errors.ts'
import { formatUnits, parseUnits } from '../lib/format.ts'

Expand Down Expand Up @@ -33,6 +34,14 @@ export function useKeystone() {
const [step, setStep] = useState<string | null>(null)
const [error, setError] = useState<ShownError | null>(null)

// Hosted keeper: writes need the team access code (config.writeAuth), typed once and kept in this browser.
const [hasAccessCode, setHasAccessCode] = useState(() => getAccessCode() !== null)
const saveAccessCode = useCallback((code: string) => {
setAccessCode(code.trim() || null)
setHasAccessCode(getAccessCode() !== null)
setError(null)
}, [])

const inFlight = useRef(false)
// When a deposit or withdrawal last ended without a confirmation (RPC timeout): kept here as well,
// since the pending feed entry may not have reached this tab (stream reconnecting).
Expand Down Expand Up @@ -93,6 +102,11 @@ export function useKeystone() {
if ((action === 'deposit' || action === 'withdraw') && e instanceof KeystoneError && e.code === 'RpcUnavailable') {
unconfirmedAt.current = Date.now()
}
if (e instanceof KeystoneError && e.code === 'Unauthorized') {
// Wrong or revoked code: forget it, the page asks again.
setAccessCode(null)
setHasAccessCode(false)
}
setError(describeError(e))
} finally {
inFlight.current = false
Expand Down Expand Up @@ -204,6 +218,8 @@ export function useKeystone() {
busy,
step,
error,
needsAccessCode: source === 'keeper' && config?.writeAuth === 'token' && !hasAccessCode,
saveAccessCode,
...actions,
}
}
Expand Down
46 changes: 46 additions & 0 deletions app/test/access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getAccessCode, setAccessCode } from '../src/api/access.ts'
import { createHttpClient } from '../src/api/http.ts'

afterEach(() => {
setAccessCode(null)
vi.unstubAllGlobals()
})

function stubFetch(status = 200, body: unknown = { sessionId: 's' }) {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status }))
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}

const headersOf = (call: unknown[]) => (call[1] as RequestInit).headers as Record<string, string>

describe('team access code', () => {
it('is sent on writes only, as a bearer token', async () => {
const fetchMock = stubFetch()
setAccessCode('team-code')
const client = createHttpClient('https://keeper.example/api')
await client.getConfig()
await client.startSession('KMMF')
expect(headersOf(fetchMock.mock.calls[0]!).authorization).toBeUndefined()
expect(headersOf(fetchMock.mock.calls[1]!).authorization).toBe('Bearer team-code')
})

it('sends nothing when no code is stored', async () => {
const fetchMock = stubFetch()
await createHttpClient('https://keeper.example/api').rateShock(10)
expect(headersOf(fetchMock.mock.calls[0]!).authorization).toBeUndefined()
})

it('surfaces a refused code as Unauthorized', async () => {
stubFetch(401, { error: { code: 'Unauthorized', message: 'A valid access code is required for this action' } })
await expect(createHttpClient('https://keeper.example/api').settleDefault()).rejects.toMatchObject({ code: 'Unauthorized', status: 401 })
})

it('stores and clears the code', () => {
setAccessCode('abc')
expect(getAccessCode()).toBe('abc')
setAccessCode(null)
expect(getAccessCode()).toBeNull()
})
})
25 changes: 25 additions & 0 deletions app/test/demo-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
})

async function configFor(search: string | null) {
vi.resetModules()
if (search !== null) vi.stubGlobal('window', { location: { search } })
return import('../src/config.ts')
}

describe('?demo', () => {
it('turns the mock keeper on for that visit only', async () => {
expect((await configFor('?demo')).USE_MOCKS).toBe(true)
expect((await configFor('?foo=1&demo')).USE_MOCKS).toBe(true)
})

it('leaves the real keeper alone otherwise', async () => {
expect((await configFor('')).USE_MOCKS).toBe(false)
expect((await configFor('?demonstration=1')).DEMO_REQUESTED).toBe(false)
expect((await configFor(null)).USE_MOCKS).toBe(false) // no window at all (tests, server)
})
})
Loading