lux-bank runs bankd v1.0.18, and Lux ID is the one sign-in the suite proves

bankd v1.0.18 takes only a lux.id access token issued to lux-financial for
the lux org, maps only a member of the admin org to _superusers, and listens
on HTTP alone. Its pod reads none of the bank-secrets keys, so the five env
references to them are gone.

bank-docs states the amd64 node selector its pod already runs with.

e2e/40-lux-financial proves the one way in: neither console serves a password
input or ships a credential in its JS; every sign-in screen hands off to lux.id
with client lux-financial, PKCE S256 and the console's /auth/callback; bankd
answers 404 on every password and Base sign-in route, the code exchange takes
only lux-financial by code or refresh, and a token lux.id did not issue to
lux-financial reads as anonymous. The password-form sign-in and the seeded
ledger totals it asserted are deleted: neither exists.

k8s/lux-k8s/{bank-api,bank-dash,bank-docs,bank-sandbox,bankd,financial} and
scripts/seed-bank-admin.ts are deleted. deploy/lux-bank declares the bank,
lux.financial is a static site, and no host those files name resolves.


Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-23 05:39:03 -07:00
co-authored by Hanzo Dev
parent fb9e35f11a
commit a32988281e
19 changed files with 105 additions and 1036 deletions
+3
View File
@@ -8,6 +8,9 @@ image:
repository: ghcr.io/luxbank/financial-docs
tag: 1.1.2
pullPolicy: Always
# The image is built for linux/amd64 only.
nodeSelector:
kubernetes.io/arch: amd64
partOf: lux-financial
resources:
limits:
+4 -31
View File
@@ -6,8 +6,8 @@ selectorLabels:
app: bankd
image:
repository: ghcr.io/luxfi/bank
tag: v1.0.16
digest: sha256:81fad895e3fc60b044cc2ab34ece5352f334f7bef4af101a3a1688eca1e42521
tag: v1.0.18
digest: sha256:069f200ee39f8a7b167bd6cb30b55b9fafbf7ed208e64d7b0000390a0ec47092
pullPolicy: IfNotPresent
# One manifest under that tag, linux/amd64 — stated beside the image because it
# describes the bytes. A cluster that is all amd64 is unaffected; a mixed one
@@ -23,33 +23,6 @@ args:
- --dir
- /data
env:
- name: BANKD_DIR
value: /data
- name: JWT_SECRET
valueFrom:
secretKeyRef:
key: jwt-secret
name: bank-secrets
- name: ENCRYPT_KEY
valueFrom:
secretKeyRef:
key: encrypt-key
name: bank-secrets
- name: SENDGRID_KEY
valueFrom:
secretKeyRef:
key: sendgrid-key
name: bank-secrets
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
key: sentry-dsn
name: bank-secrets
- name: GOOGLE_RECAPTCHA_SECRET_KEY
valueFrom:
secretKeyRef:
key: recaptcha-secret-key
name: bank-secrets
- name: BANK_SANDBOX
value: 'true'
resources:
@@ -70,8 +43,8 @@ ports:
# could not see — it reads the Deployment, and a claim a Deployment merely MOUNTS
# is not part of it — so the values described a pod that names a volume nobody
# declares, and the scheduler refuses such a pod outright: no node is available,
# forever, with no image ever pulled. 20Gi RWO is what k8s/lux-k8s/bankd carried
# before that directory was superseded.
# forever, with no image ever pulled. 20Gi RWO is the size bankd's ledger has
# always been given.
#
# No storageClass on purpose. Naming one binds this file to one cluster's
# provisioner; omitting it takes the cluster's default, which is the block class
+98 -175
View File
@@ -2,18 +2,18 @@ import { test, expect, Page, APIRequestContext } from '@playwright/test'
import { httpStatus, snap } from './helpers'
// ═══════════════════════════════════════════════════════════════════════════
// Lux Financial — authenticated bank flow proof suite
//
// Unlike 18/19-broker (unauth UI + responsive audits), this suite logs in with
// the sandbox superuser and ASSERTS real post-login state: the customer's total
// balance, the balances list, and the admin control-plane nav + pages. It also
// proves the acm white-label (?brand=acm → light theme + "acm" wordmark).
// Lux Financial — one way in, and the surfaces around it
//
// Customer : https://app.lux.financial (Vite SPA → bankd @ api.lux.financial)
// Admin : https://admin.lux.financial (Vite SPA → bankd, testnet custody)
// Admin : https://admin.lux.financial (Vite SPA → bankd)
// Docs : https://docs.lux.financial (Docusaurus)
// Backend : api.lux.financial (Hanzo Base app "bankd", /v1/*)
//
// Both consoles sign in only with Lux ID: the public client lux-financial, PKCE
// S256, back to https://<console>/auth/callback. No console serves a password
// input or ships a credential in its JS, and bankd serves no password route and
// takes no token but a lux.id access token issued to lux-financial.
//
// Resilient by design: unreachable surfaces (HTTP 0 / ≥500) skip-with-note
// rather than hard-failing the run.
// ═══════════════════════════════════════════════════════════════════════════
@@ -21,23 +21,9 @@ import { httpStatus, snap } from './helpers'
const APP = 'https://app.lux.financial'
const ADMIN = 'https://admin.lux.financial'
const DOCS = 'https://docs.lux.financial'
// Credentials come from the environment, never from this file — a password in
// a tracked file is a plaintext password however "sandbox" the account is.
// This suite's contract is skip-with-note for anything it cannot reach, so an
// unset password skips the authenticated tests rather than reddening the run;
// the dedicated sign-in proof (50-login.spec.ts) is the one that must FAIL.
const CREDS = {
email: process.env.LUX_FINANCIAL_USER || '[email protected]',
password: process.env.LUX_FINANCIAL_PASSWORD || '',
}
function haveCreds(): boolean {
if (!CREDS.password) {
console.log(' WARN: LUX_FINANCIAL_PASSWORD unset — skipping the authenticated tests')
return false
}
return true
}
const API = 'https://api.lux.financial'
const IDP_HOST = 'lux.id'
const CLIENT = 'lux-financial'
// ---------------------------------------------------------------------------
// Reachability cache
@@ -74,79 +60,6 @@ async function bodyLightness(page: Page): Promise<number> {
})
}
async function loginCustomer(page: Page) {
test.skip(!haveCreds(), 'LUX_FINANCIAL_PASSWORD is not set')
await page.goto(`${APP}/login`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.locator('input[type=email]').first().fill(CREDS.email)
await page.locator('input[type=password]').first().fill(CREDS.password)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await page.waitForURL(/\/app/, { timeout: 30_000 })
await expect(page.locator('body')).toContainText('21,818.45', { timeout: 20_000 })
}
async function loginAdmin(page: Page) {
test.skip(!haveCreds(), 'LUX_FINANCIAL_PASSWORD is not set')
await page.goto(`${ADMIN}/login`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.locator('input[type=email]').first().fill(CREDS.email)
await page.locator('input[type=password]').first().fill(CREDS.password)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
// Admin lands on "/" — wait for the Overview shell (nav) to hydrate.
await expect(page.getByRole('link', { name: 'Overview', exact: true })).toBeVisible({ timeout: 30_000 })
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Customer — app.lux.financial (authenticated)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Bank Customer', () => {
test.describe.configure({ mode: 'serial' })
let page: Page
let down = false
test.beforeAll(async ({ browser, request }) => {
down = !(await reachable(request, 'app', APP))
if (down) return
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } })
page = await ctx.newPage()
await loginCustomer(page)
})
test.afterAll(async () => {
await page?.context().close().catch(() => {})
})
test('logs in → dashboard shows total balance $21,818.45 + Welcome back', async () => {
test.skip(down, 'app.lux.financial unreachable')
expect(page.url()).toContain('/app')
await expect(page.locator('body')).toContainText('Welcome back')
await expect(page.locator('body')).toContainText('21,818.45')
await expect(page.locator('body')).toContainText(/Across 5 balances/i)
await snap(page, 'luxfin-customer-dashboard')
})
test('dashboard lists the 5 currency balances (USD/EUR/GBP/LUX/DAI)', async () => {
test.skip(down, 'app.lux.financial unreachable')
const body = page.locator('body')
await expect(body).toContainText(/Balances/i)
for (const code of ['USD', 'EUR', 'GBP', 'LUX', 'DAI']) {
await expect(body).toContainText(code)
}
// Concrete per-currency figures rendered from bankd, not placeholders.
await expect(body).toContainText('$12,500.00') // USD
await expect(body).toContainText('€3,200.00') // EUR
await expect(body).toContainText('250 LUX') // LUX
})
test('dashboard renders cards + recent activity (real app, not marketing shell)', async () => {
test.skip(down, 'app.lux.financial unreachable')
const body = page.locator('body')
await expect(body).toContainText(/CARDS/i)
await expect(body).toContainText(/RECENT ACTIVITY/i)
// A known seeded transaction proves the activity feed reads bankd.
await expect(body).toContainText(/Payroll — Acme Corp/i)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Bank Customer — acm white-label (unauthenticated login screen)
// ═══════════════════════════════════════════════════════════════════════════
@@ -175,91 +88,57 @@ test.describe('Lux Financial — Bank Customer white-label', () => {
})
// ═══════════════════════════════════════════════════════════════════════════
// Bank Admin — admin.lux.financial (authenticated control plane)
// Bank Admin — acm white-label (unauthenticated login screen)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Bank Admin', () => {
test.describe.configure({ mode: 'serial' })
let page: Page
let down = false
test.beforeAll(async ({ browser, request }) => {
down = !(await reachable(request, 'admin', ADMIN))
if (down) return
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } })
page = await ctx.newPage()
await loginAdmin(page)
})
test.afterAll(async () => {
await page?.context().close().catch(() => {})
})
test('logs in → Overview shows portfolio metrics', async () => {
test.skip(down, 'admin.lux.financial unreachable')
const body = page.locator('body')
await expect(body).toContainText('Overview')
await expect(body).toContainText(/Total liquidity/i)
await expect(body).toContainText(/Pending KYC/i)
await expect(body).toContainText(/Recent activity/i)
await snap(page, 'luxfin-admin-overview')
})
test('control-plane nav exposes MPC, Safes, KMS, Nodes', async () => {
test.skip(down, 'admin.lux.financial unreachable')
for (const item of ['MPC', 'Safes', 'KMS', 'Nodes']) {
await expect(page.getByRole('link', { name: item, exact: true }).first()).toBeVisible()
}
})
test('MPC / KMS / Nodes pages render their sections', async () => {
test.skip(down, 'admin.lux.financial unreachable')
const checks: Array<[string, string]> = [
['/mpc', 'MPC'],
['/kms', 'KMS'],
['/nodes', 'Nodes'],
]
for (const [path, heading] of checks) {
await page.goto(`${ADMIN}${path}`, { waitUntil: 'networkidle', timeout: 30_000 })
await page.waitForTimeout(1500)
const h = page.locator('main h1, main h2, h1, h2').first()
await expect(h).toContainText(heading)
const mainLen = (await page.locator('main').innerText().catch(() => '')).trim().length
console.log(` admin ${path}: heading OK, main content ${mainLen} chars`)
expect(mainLen).toBeGreaterThan(50)
}
})
// KNOWN DEFECT (documented, not hidden): the Safes route throws
// "Invalid currency code : USDC" (USDC passed to Intl.NumberFormat, which
// only accepts ISO-4217 codes) and renders a blank page. The nav item exists
// (proven above) but the page does not render. Encoded as an expected failure
// so the suite stays honest & green; when the USDC formatting bug is fixed
// this test will pass unexpectedly and flag removal of the marker.
test('Safes page renders (KNOWN BROKEN: Intl currency "USDC" crash)', async () => {
test.skip(down, 'admin.lux.financial unreachable')
test.fail(true, 'Safes route crashes: RangeError Invalid currency code : USDC')
const errors: string[] = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto(`${ADMIN}/safes`, { waitUntil: 'networkidle', timeout: 30_000 })
await page.waitForTimeout(2500)
if (errors.length) console.log(` Safes pageerror: ${errors[0]}`)
const h = page.locator('main h1, main h2, h1, h2').first()
await expect(h).toContainText('Safes')
})
test('?brand=acm renders LIGHT theme with "acm" wordmark (admin login)', async ({ page: fresh, request }) => {
test.describe('Lux Financial — Bank Admin white-label', () => {
test('?brand=acm renders LIGHT theme with "acm" wordmark (admin login)', async ({ page, request }) => {
test.skip(!(await reachable(request, 'admin', ADMIN)), 'admin.lux.financial unreachable')
await fresh.goto(`${ADMIN}/login?brand=acm`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await fresh.waitForTimeout(1500)
const L = await bodyLightness(fresh)
await page.goto(`${ADMIN}/login?brand=acm`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.waitForTimeout(1500)
const L = await bodyLightness(page)
console.log(` admin acm background lightness: ${L.toFixed(3)}`)
expect(L).toBeGreaterThan(0.6)
await expect(fresh.locator('body')).toContainText('acm')
await snap(fresh, 'luxfin-admin-acm-light')
await expect(page.locator('body')).toContainText('acm')
await snap(page, 'luxfin-admin-acm-light')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// One way in — Lux ID, from every sign-in screen
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — one way in', () => {
const screens: Array<[string, string, RegExp]> = [
[APP, '/login', /sign in with lux id/i],
[APP, '/signup', /continue with lux id/i],
[ADMIN, '/login', /sign in with lux id/i],
]
for (const [origin, path, button] of screens) {
test(`${new URL(origin).host}${path}: no password, Lux ID with PKCE`, async ({ page, request }) => {
test.skip(!(await reachable(request, origin, origin)), `${origin} unreachable`)
const bundles: string[] = []
page.on('response', async (r) => {
if (/\.js(\?|$)/.test(r.url())) bundles.push(await r.text().catch(() => ''))
})
await page.goto(`${origin}${path}`, { waitUntil: 'networkidle', timeout: 45_000 })
expect(await page.locator('input[type=password]').count(), 'a console never asks for a password').toBe(0)
expect(bundles.length).toBeGreaterThan(0)
for (const js of bundles) expect(js, 'a credential shipped in the JS').not.toMatch(/Ilove|DEMO_PASSWORD|demoLogin/)
await Promise.all([
page.waitForURL((u) => u.host === IDP_HOST, { timeout: 30_000 }),
page.getByRole('button', { name: button }).click(),
])
const q = new URL(page.url()).searchParams
expect(q.get('client_id')).toBe(CLIENT)
expect(q.get('response_type')).toBe('code')
expect(q.get('code_challenge_method')).toBe('S256')
expect(q.get('code_challenge') ?? '').toMatch(/^[A-Za-z0-9_-]{43,128}$/)
expect(q.get('client_secret'), 'a browser client carries no secret').toBeNull()
expect(q.get('redirect_uri')).toBe(`${origin}/auth/callback`)
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// Docs — docs.lux.financial
// ═══════════════════════════════════════════════════════════════════════════
@@ -287,6 +166,50 @@ test.describe('Lux Financial — Backend (bankd)', () => {
const json = (await res.json()) as { code?: number }
expect(json.code).toBe(200)
})
test('bankd serves no password route and mints no token of its own', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
for (const path of [
'/v1/bank/login',
'/v1/platform/auth/login',
'/v1/collections/users/auth-with-password',
'/v1/collections/_superusers/auth-with-password',
'/v1/collections/users/auth-refresh',
]) {
const res = await request.post(`${API}${path}`, { data: {}, failOnStatusCode: false })
expect(res.status(), path).toBe(404)
}
})
test('the code exchange takes only lux-financial, by code or refresh', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
for (const form of [
{ grant_type: 'password', client_id: CLIENT, username: 'x', password: 'x' },
{ grant_type: 'authorization_code', client_id: 'hanzo-cli', code: 'x' },
]) {
const res = await request.post(`${API}/v1/iam/oauth/token`, { form, failOnStatusCode: false })
expect(res.status(), form.grant_type).toBe(400)
expect((await res.json()).error).toBe('unauthorized_client')
}
})
test('a token lux.id did not issue to lux-financial is anonymous', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
// Unsigned, and naming another issuer, client and org: the shapes bankd refuses.
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
const exp = Math.floor(Date.now() / 1000) + 3600
const forged = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({
iss: 'https://hanzo.id', aud: ['hanzo-cli'], owner: 'admin', sub: 'x', exp,
tokenType: 'access-token', isAdmin: true, orgs: [{ org: 'admin' }],
})}.`
for (const auth of [undefined, `Bearer ${forged}`]) {
const headers = auth ? { Authorization: auth } : undefined
const res = await request.get(`${API}/v1/bank/overview`, { headers, failOnStatusCode: false })
expect(res.status()).toBe(401)
const su = await request.get(`${API}/v1/collections/_superusers/records`, { headers, failOnStatusCode: false })
expect(su.status()).toBe(403)
}
})
})
// ═══════════════════════════════════════════════════════════════════════════
-98
View File
@@ -1,98 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bank-api
namespace: lux-bank
labels:
app: bank-api
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
replicas: 1
selector:
matchLabels:
app: bank-api
template:
metadata:
labels:
app: bank-api
spec:
imagePullSecrets:
- name: ghcr-secret
containers:
- name: bank-api
# TODO: bank-api has no semver tag in GHCR; pinned to verified live commit-sha.
# Auto-bump should switch to semver once luxfi/bank publishes tagged releases.
image: ghcr.io/luxfi/bank-api:291ba2151658f8729a6e32cf8cfdcf80ec17e170
imagePullPolicy: Always
ports:
- containerPort: 4000
name: http
env:
- name: PORT
value: "4000"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: bank-secrets
key: database-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: bank-secrets
key: jwt-secret
- name: ENCRYPT_KEY
valueFrom:
secretKeyRef:
name: bank-secrets
key: encrypt-key
- name: SENDGRID_KEY
valueFrom:
secretKeyRef:
name: bank-secrets
key: sendgrid-key
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: bank-secrets
key: sentry-dsn
- name: GOOGLE_RECAPTCHA_SECRET_KEY
valueFrom:
secretKeyRef:
name: bank-secrets
key: recaptcha-secret-key
envFrom:
- configMapRef:
name: bank-config
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
httpGet:
path: /
port: 4000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 4000
initialDelaySeconds: 30
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: bank-api
namespace: lux-bank
spec:
selector:
app: bank-api
ports:
- name: http
port: 4000
targetPort: 4000
-5
View File
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
-69
View File
@@ -1,69 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bank-dash
namespace: lux-bank
labels:
app: bank-dash
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
replicas: 1
selector:
matchLabels:
app: bank-dash
template:
metadata:
labels:
app: bank-dash
spec:
imagePullSecrets:
- name: ghcr-secret
containers:
- name: bank-dash
# @luxfi/bank-dash — Vite SPA served by the stdlib dashd static+proxy
# server (serves the SPA, proxies /v1 -> bankd for same-origin API + IAM).
image: ghcr.io/luxfi/bank-dash:v1.6.0
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
env:
- name: BANK_UPSTREAM
value: http://bankd.lux-bank.svc.cluster.local
- name: PORT
value: "3000"
- name: DIST_DIR
value: /app/dist
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 20
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: bank-dash
namespace: lux-bank
spec:
selector:
app: bank-dash
ports:
- name: http
port: 3000
targetPort: 3000
-52
View File
@@ -1,52 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bank-dash
namespace: lux-bank
labels:
app: bank-dash
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: ingress
tls:
- hosts:
- app.lux.financial
- lux.finance
- www.lux.finance
secretName: bank-dash-tls
rules:
# app.lux.financial — platform / white-label surface.
- host: app.lux.financial
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bank-dash
port:
number: 3000
# lux.finance — the B2C home: landing, membership plans, login.
- host: lux.finance
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bank-dash
port:
number: 3000
- host: www.lux.finance
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bank-dash
port:
number: 3000
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
- ingress.yaml
-59
View File
@@ -1,59 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bank-docs
namespace: lux-bank
labels:
app: bank-docs
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-financial
spec:
replicas: 1
selector:
matchLabels:
app: bank-docs
template:
metadata:
labels:
app: bank-docs
spec:
containers:
- name: bank-docs
image: ghcr.io/luxbank/financial-docs:1.1.2
imagePullPolicy: Always
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bank-docs
namespace: lux-bank
spec:
selector:
app: bank-docs
ports:
- name: http
port: 3000
targetPort: 3000
-5
View File
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
-191
View File
@@ -1,191 +0,0 @@
# sandbox.lux.financial — the seeded demo bank. Same images as production,
# BANK_SANDBOX=true. app.lux.financial flips to real money (BANK_SANDBOX=false
# + issuer credentials from KMS) once SF Private issues API access; this stack
# keeps the demo alive either way.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bankd-sandbox-data
namespace: lux-bank
labels:
app: bankd-sandbox
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
accessModes: [ReadWriteOnce]
storageClassName: do-block-storage
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bankd-sandbox
namespace: lux-bank
labels:
app: bankd-sandbox
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: bankd-sandbox
template:
metadata:
labels:
app: bankd-sandbox
spec:
imagePullSecrets:
- name: ghcr-secret
containers:
- name: bankd
image: ghcr.io/luxfi/bank:v1.6.0
imagePullPolicy: Always
ports:
- containerPort: 8070
name: http
env:
- name: BANKD_DIR
value: /data
- name: BANK_SANDBOX
value: "true"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
httpGet:
path: /v1/bank/health
port: 8070
initialDelaySeconds: 8
periodSeconds: 10
livenessProbe:
httpGet:
path: /v1/bank/health
port: 8070
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: bankd-sandbox-data
---
apiVersion: v1
kind: Service
metadata:
name: bankd-sandbox
namespace: lux-bank
spec:
selector:
app: bankd-sandbox
ports:
- name: http
port: 80
targetPort: 8070
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bank-dash-sandbox
namespace: lux-bank
labels:
app: bank-dash-sandbox
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
replicas: 1
selector:
matchLabels:
app: bank-dash-sandbox
template:
metadata:
labels:
app: bank-dash-sandbox
spec:
imagePullSecrets:
- name: ghcr-secret
containers:
- name: bank-dash
image: ghcr.io/luxfi/bank-dash:v1.6.0
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
env:
- name: BANK_UPSTREAM
value: http://bankd-sandbox.lux-bank.svc.cluster.local
- name: PORT
value: "3000"
- name: DIST_DIR
value: /app/dist
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: bank-dash-sandbox
namespace: lux-bank
spec:
selector:
app: bank-dash-sandbox
ports:
- name: http
port: 3000
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bank-sandbox
namespace: lux-bank
labels:
app: bank-dash-sandbox
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: ingress
tls:
- hosts:
- sandbox.lux.financial
secretName: bank-sandbox-tls
rules:
- host: sandbox.lux.financial
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bank-dash-sandbox
port:
number: 3000
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
-97
View File
@@ -1,97 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bankd-data
namespace: lux-bank
labels:
app: bankd
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
accessModes: [ReadWriteOnce]
storageClassName: do-block-storage
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bankd
namespace: lux-bank
labels:
app: bankd
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-bank
spec:
replicas: 1
# Single RWO PVC — Recreate avoids Multi-Attach deadlock on rollout.
strategy:
type: Recreate
selector:
matchLabels:
app: bankd
template:
metadata:
labels:
app: bankd
spec:
imagePullSecrets:
- name: ghcr-secret
containers:
- name: bankd
# bankd — Go on hanzoai/base. BaaS API (IAM-native auth via lux.id).
# This is the app.lux.financial backend. It runs sandbox until the
# issuer credentials land in KMS; the real-money flip is
# BANK_SANDBOX=false + SFPRIVATE_URL / SFPRIVATE_API_KEY (KMS:
# providers/lux/sfprivate-api-key). The demo home is bankd-sandbox
# at sandbox.lux.financial.
image: ghcr.io/luxfi/bank:v1.6.0
imagePullPolicy: Always
ports:
- containerPort: 8070
name: http
env:
- name: BANKD_DIR
value: /data
- name: BANK_SANDBOX
value: "true"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
httpGet:
path: /v1/bank/health
port: 8070
initialDelaySeconds: 8
periodSeconds: 10
livenessProbe:
httpGet:
path: /v1/bank/health
port: 8070
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: bankd-data
---
apiVersion: v1
kind: Service
metadata:
name: bankd
namespace: lux-bank
spec:
selector:
app: bankd
ports:
- name: http
port: 80
targetPort: 8070
-5
View File
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
-64
View File
@@ -1,64 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lux-financial
namespace: lux-bank
labels:
app: lux-financial
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-financial
spec:
replicas: 2
selector:
matchLabels:
app: lux-financial
template:
metadata:
labels:
app: lux-financial
spec:
containers:
- name: spa
image: ghcr.io/luxfi/lux-financial:v1.4.0
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
env:
- name: PORT
value: "3000"
- name: ROOT
value: "/public"
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 128Mi
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: lux-financial
namespace: lux-bank
spec:
selector:
app: lux-financial
ports:
- name: http
port: 80
targetPort: 3000
-38
View File
@@ -1,38 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lux-financial
namespace: lux-bank
labels:
app.kubernetes.io/managed-by: lux-universe
app.kubernetes.io/part-of: lux-financial
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: ingress
tls:
- hosts:
- lux.financial
- www.lux.financial
secretName: lux-financial-tls
rules:
- host: lux.financial
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lux-financial
port:
number: 80
- host: www.lux.financial
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lux-financial
port:
number: 80
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-bank
resources:
- deployment.yaml
- ingress.yaml
-6
View File
@@ -36,12 +36,6 @@ resources:
- safe
- vote
- blog
- bank-docs
- bank-api
- bank-dash
- bank-sandbox
- bankd
- financial
# luxd is deployed only from deploy/lux-{mainnet,testnet,devnet}/luxd.yaml.
# Keeping a validator StatefulSet here would create a second, stale rollout path.
- validators/crd-resources.yaml
-124
View File
@@ -1,124 +0,0 @@
/**
* Seed Admin User for Lux Financial
*
* Creates a SuperAdmin user with a corresponding Account entity.
* Password is hashed with bcrypt (cost factor 14) matching @luxbank/misc.
*
* Required env vars:
* DATABASE_URL — PostgreSQL connection string
* ADMIN_EMAIL — Admin email (e.g. [email protected])
* ADMIN_PASSWORD — Admin password (from KMS, never hardcoded)
* ADMIN_FIRSTNAME — First name
* ADMIN_LASTNAME — Last name
*
* Usage:
* DATABASE_URL="postgresql://..." ADMIN_EMAIL="[email protected]" ADMIN_PASSWORD="..." \
* ADMIN_FIRSTNAME="Zach" ADMIN_LASTNAME="Kelling" \
* npx --package=pg --package=bcryptjs --package=ts-node ts-node scripts/seed-bank-admin.ts
*
* Or via kubectl exec into the API pod:
* kubectl exec -n lux-bank <pod> -- node -e '...'
*/
import { randomUUID } from "crypto";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Client } = require("pg");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const bcrypt = require("bcryptjs");
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const ADMIN_FIRSTNAME = process.env.ADMIN_FIRSTNAME || "Admin";
const ADMIN_LASTNAME = process.env.ADMIN_LASTNAME || "User";
const ADMIN_ROLE = "admin:super";
const BCRYPT_ROUNDS = 14;
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error("ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required.");
process.exit(1);
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required.");
process.exit(1);
}
const client = new Client({ connectionString: databaseUrl });
await client.connect();
try {
// Check if user already exists
const existing = await client.query(
'SELECT uuid FROM "user" WHERE username = $1',
[ADMIN_EMAIL]
);
if (existing.rows.length > 0) {
// Update password
const hash = await bcrypt.hash(ADMIN_PASSWORD, BCRYPT_ROUNDS);
await client.query(
'UPDATE "user" SET password = $1 WHERE username = $2',
[hash, ADMIN_EMAIL]
);
console.log(`Updated password for ${ADMIN_EMAIL} (uuid: ${existing.rows[0].uuid})`);
return;
}
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, BCRYPT_ROUNDS);
const now = new Date();
// Create account
const accountUuid = randomUUID();
await client.query(
'INSERT INTO account (uuid, created_at, updated_at) VALUES ($1, $2, $3)',
[accountUuid, now, now]
);
// Create contact
const contactUuid = randomUUID();
await client.query(
'INSERT INTO contact (uuid, country, is_approved, is_sub_account, account_uuid, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[contactUuid, "US", true, false, accountUuid, now, now]
);
// Create user
const userUuid = randomUUID();
await client.query(
'INSERT INTO "user" (uuid, username, password, firstname, lastname, has_accepted_terms, verified_at, contact_uuid, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
[userUuid, ADMIN_EMAIL, passwordHash, ADMIN_FIRSTNAME, ADMIN_LASTNAME, true, now, contactUuid, now, now]
);
// Create client
const clientUuid = randomUUID();
await client.query(
'INSERT INTO client (uuid, created_at, updated_at) VALUES ($1, $2, $3)',
[clientUuid, now, now]
);
// Link user to client
const ucUuid = randomUUID();
await client.query(
'INSERT INTO user_clients (uuid, user_uuid, client_uuid) VALUES ($1, $2, $3)',
[ucUuid, userUuid, clientUuid]
);
// Set role
const ucmUuid = randomUUID();
await client.query(
'INSERT INTO user_clients_metadata (uuid, user_client_uuid, role) VALUES ($1, $2, $3)',
[ucmUuid, ucUuid, ADMIN_ROLE]
);
console.log(`Seeded admin: ${ADMIN_EMAIL} (uuid: ${userUuid}, role: ${ADMIN_ROLE})`);
} finally {
await client.end();
}
}
main().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});