Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기distributes expenses among members in a group
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Tally — split a shared tab, settle up fast.
A full-stack expense splitter built for the "Expense Splitter Web App" intern task:
React + Node/Express + PostgreSQL, deployed and tested. Each tab is private to the people in
it, nobody can log a payment in someone else's name, and the money rules are built so that nobody
can get out of what they owe.
_The API runs on a free Render instance, so the first request after a quiet period can take
~30–60 seconds while it wakes up._README 전문 보기 ↗Built by Christian Tetteh a full stack developer intern at Career Ghana
A full-stack expense splitter built for the "Expense Splitter Web App" intern task: React + Node/Express + PostgreSQL, deployed and tested. Each tab is private to the people in it, nobody can log a payment in someone else's name, and the money rules are built so that nobody can get out of what they owe.
Live demo: https://tally-splitter.vercel.app
The API runs on a free Render instance, so the first request after a quiet period can take ~30–60 seconds while it wakes up.
Core requirements
Beyond the brief
expense-splitter/
backend/ Express API + PostgreSQL (Node)
frontend/ React app (Vite)
The two questions this is built to answer: who can see a tab, and can anyone dodge what they owe.
| Who | What they can see |
|---|---|
| Not logged in | Nothing. Every tab and invite endpoint needs a session. |
| Logged in, not in the tab | Nothing. Every tab route answers with the same 404 Tab not found. whether the tab exists or not, so tab ids can't be probed. Tab ids are random UUIDs, not counters. |
| Holding an invite link | The tab's name, its owner's name and the member count, nothing else, until the owner approves them. |
| Member | The whole tab: people, expenses, repayments, balances. |
| Owner | All of the above, plus the invite link and pending join requests. |
| Former member (left / removed) | Nothing any more. |
GET /api/groups only returns tabs you're currently in. There is no endpoint that lists anyone
else's tabs.
One principle runs through all of them: nothing can make your balance worse without your OK, and nobody can leave with anything unresolved.
| Attempt | What happens |
|---|---|
| Record an expense as if someone else paid it | Impossible. The payer is always the logged-in user; any payer field in the request is ignored. |
| Cancel your debt with a fake expense charged to your creditor ("I paid $20, split with Ama") | The charge is pending and changes nothing until Ama accepts it. If she declines, the debt stands and the dispute is on the record. |
| Shift your debt onto a third person with a fake charge | Same: it doesn't count unless they accept. |
| Decline a genuine bill, then leave | 409. A declined share is an open dispute. Neither the payer nor the decliner can leave, and the owner can't remove them, until the decliner accepts after all or the payer withdraws the charge. |
| Delete or void an expense you owe on | 403. Only the person who paid can void an expense, and voiding never deletes it: it stays in the history, marked "voided by X". |
| Claim "I paid them" to clear your debt | The claim is pending and changes nothing until the receiver confirms. You can't confirm your own claim. |
| Confirm or decline someone else's payment | 403. Only the receiver can confirm or decline. A pending claim can only be withdrawn by the person who made it. |
| Re-open a settled debt by undoing a confirmed payment | The receiver gets 15 minutes to fix a typo. After that, only the payer can undo it, which only raises their own debt. |
| Leave the tab while you owe money | 409. You (or the owner removing you) can only leave at a zero balance with nothing pending. |
| Leave, then have an old expense voided so the debt re-opens | 409. Expenses and payments involving someone who has left are locked. |
| Race a "leave" against a new bill | The tab is row-locked for every money-moving write, so one of them fails cleanly. This is covered by a concurrency test. |
| Point an expense at someone from a different tab | Rejected by the API, and independently by the database: every member reference is a composite (tab, member) foreign key. |
| Pick the display name "you" (or "yоu" with a Cyrillic о, or fullwidth "you") so others see "Kwesi owes you" | Rejected. Names are NFKC-folded, can't mix Latin with look-alike alphabets, and can't use the words or symbols the UI uses for itself. |
__Host- cookie.
Only a SHA-256 hash is stored in the database. Logging out deletes the session on the server.
Sessions expire after 14 days.| Attempt | What happens |
|---|---|
| Find out whether an email has an account through "Forgot password" | Impossible. POST /api/auth/forgot gives the same 200 and message for a known email, an unknown one, malformed input and a rate-limited request. The lookup and the email happen after the response is sent, so timing doesn't differ either. |
| Steal a reset link from the database or logs | The database holds only the SHA-256 of each token. The link carries the token in the URL fragment (/reset#...), which browsers never send to a server, so it isn't in access logs or Referer headers. The page removes it from the address bar as soon as it loads. |
Poison the link with a forged Host / X-Forwarded-Host | The link is built from the APP_ORIGIN setting only, never from the request. |
| Guess a token | 256 random bits, and /api/auth/reset allows 10 tries per address per 15 minutes. |
| Reuse a link, or use an old one after asking for a new one | Links work once, for 30 minutes. A new request retires earlier links; using a link retires the rest. Unknown, used and expired links all get the same 400. |
| Race two submits of one link | The reset row is locked FOR UPDATE inside the transaction, so exactly one wins. |
| Keep a stolen session after the owner resets | Resetting deletes every session of that account. It also doesn't log the person in; they log in again with the new password. |
| Use a weak password through the reset form | The same rules as signup apply (10+ characters, not the email). A rejected password leaves the link usable. |
| Flood someone's inbox | 5 requests per address per 15 minutes and 3 per email per hour. Past either limit the answer is unchanged and nothing is sent. |
Reset email is sent through Brevo's REST API (backend/lib/mailer.js,
built-in fetch, no extra dependency). It needs BREVO_API_KEY, MAIL_FROM (a sender verified in
Brevo) and optionally MAIL_FROM_NAME. Without a key, nothing is sent: in development the message
and link are printed to the server console; in production a warning is logged and the request still
gets its normal answer. Turn off Brevo's click and open tracking for transactional mail so links
aren't rewritten through a third party.
frontend/middleware.js) forwards /api/* to the backend with a shared secret and the
visitor's real IP, taken from Vercel's own connection data. The backend refuses anything without
the secret, so calling the onrender.com URL directly gets 403. X-Forwarded-For is never
trusted, so nobody can fake their address to get around rate limits.application/json, must not be a cross-site fetch (Sec-Fetch-Site), and
must come from the app's own Origin. This is on top of SameSite cookies.script-src 'self', frame-ancestors 'none', …),
X-Frame-Options: DENY, Referrer-Policy: no-referrer (so invite links never leak in
headers), and HSTS. All API responses are Cache-Control: no-store.dangerouslySetInnerHTML anywhere. All
SQL is parameterised. Text input is stripped of control characters and Unicode bidi overrides
(which can make a description display as something other than what it says).?next= rejects //host, /\host and absolute
URLs). react-router is on a version patched for the related open-redirect advisory.npm audit: 0 known vulnerabilities in either the backend or the frontend.BREVO_API_KEY and MAIL_FROM
set on the server, "Forgot your password?" looks like it worked but no email is sent. Anyone who
controls the inbox of the account's email can reset the password, as with any email-based reset.
Someone who knows your email can use up its 3 requests an hour, which delays your reset email
but never reveals anything or changes your password.Every expense is split into explicit per-person shares the moment it's added, so each person's net balance is:
net = (accepted shares of expenses they paid) − (their own accepted shares)
+ (repayments they sent) − (repayments they received)
Only accepted shares of expenses that haven't been voided count, and only repayments the receiver has confirmed. Pending, declined and withdrawn shares move nothing. Positive means the tab owes you; negative means you owe. Balances always sum to exactly zero.
computeSettlement then repeatedly matches whoever owes the most against whoever is owed the
most, the same greedy approach Splitwise uses. That never takes more than
(people with a nonzero balance) − 1 payments.
# database
createdb tally_dev
# backend
cd backend
cp .env.example .env # set DATABASE_URL
npm install
npm run migrate # versioned migrations, each applied once
npm run dev # http://localhost:4002
# frontend (separate terminal)
cd frontend
npm install
npm run dev # http://localhost:5173 — proxies /api to :4002
See TESTING.md for the full write-up (tests, browser run, security review findings and fixes).
createdb tally_test
# default connection is the local socket; for another server set TEST_DATABASE_URL=postgresql://user:pass@host:5432/tally_test
cd backend
npm test
131 tests. The security tests run against a real Postgres database (not mocks), so they exercise the actual queries, locks and constraints:
tests/auth.test.js: hashing, cookie flags, hashed session storage, logout revocation,
expiry, identical login errors, CSRF (form posts, foreign Origin, cross-site fetches), no
CORS, headers, oversized and malformed bodiestests/isolation.test.js: outsiders get identical 404s on every route, can't reach
expenses through another tab, the database rejects cross-tab references, invite links only let
people ask, owner-only controls, link rotation, duplicate-name labelling, injection payloads
stored literallytests/billing.test.js: payer can't be spoofed; charges count only once accepted; the
fake-expense, debt-shifting and decline-then-leave attacks (regression tests); disputes and
withdrawals; only the payer voids; payment claims need the receiver; the undo grace period;
leaving needs a zero balance and nothing open; departed members can't be re-billed; the
leave-vs-bill racetests/rateLimits.test.js: requests without the proxy secret are refused; X-Forwarded-For
can't be used to dodge limits; known addresses can't be locked out; distributed guessing is cappedtests/passwordReset.test.js (mailer mocked): identical answers for known, unknown and malformed
emails; mail only for real accounts; link host from APP_ORIGIN even with forged Host headers;
token stored only as a hash; expiry; single use; a new request retires the old link; the password
changes and all sessions are revoked; weak passwords leave the link usable; garbage tokens; a
concurrent double submit has exactly one winner; silent and 429 rate limits; CSRF guardtests/mailer.test.js: Brevo request shape, never throws, never logs the key, dev/production
behaviour without a key, dev-only outbox file (all with fetch stubbed)tests/money.test.js, tests/settlement.test.js, tests/validation.test.js: the pure logicThe full browser flow was also run end-to-end (33 checks) in headless Chromium with the production CSP applied: invite → approve → accept a charge → fake-expense attempt declined → payment claim → confirm → dispute blocks leaving → withdraw → leave; then forgot password → emailed link → new password → old password refused, new accepted, other sessions logged out. The security design was reviewed twice by an independent reviewer; every finding is fixed and covered by a test.
All routes except /api/auth/* and /api/health need a session. All writes are JSON POSTs.
| Route | Who | What |
|---|---|---|
POST /api/auth/signup · login · logout, GET /api/auth/me | anyone | Accounts and sessions |
POST /api/auth/forgot { email } · POST /api/auth/reset { token, password } | anyone | Password reset by email |
GET /api/groups | you | Tabs you're in, with your balance in each |
POST /api/groups | you | Start a tab { name } |
GET /api/groups/:id | member | The whole tab: people, expenses, payments, balances, settlement |
POST /api/groups/:id/expenses | member | Add something you paid { description, amount, participant_ids } |
POST /api/groups/:id/expenses/:eid/{accept,decline} | that participant | Answer for your share |
POST /api/groups/:id/expenses/:eid/shares/:mid/withdraw | the payer | Drop a pending or disputed charge |
POST /api/groups/:id/expenses/:eid/void | the payer | Void (never delete) an expense |
POST /api/groups/:id/payments | member | { direction: "sent" | "received", counterparty_id, amount } |
POST /api/groups/:id/payments/:pid/{confirm,decline} | the receiver | Resolve a payment |
POST /api/groups/:id/payments/:pid/void | the payer (or receiver within 15 min) | Undo a confirmed payment |
POST /api/groups/:id/payments/:pid/cancel | whoever recorded it | Withdraw a pending claim |
POST /api/groups/:id/leave | member (not owner), when square | Leave |
POST /api/groups/:id/members/:mid/remove | owner, target square | Remove someone |
POST /api/groups/:id/invite/{regenerate,disable} |
Database: Render Postgres. Migrations run on every boot (npm start), each applied once and
tracked in schema_migrations, behind an advisory lock. Free Render Postgres instances expire
after 30 days unless upgraded.
Backend: a Render web service with build cd backend && npm install and start
cd backend && npm start. Environment variables:
DATABASE_URL: the Internal Database URLPGSSL=false: internal networkNODE_ENV=production: turns on Secure / __Host- cookiesAPP_ORIGIN=https://<your-frontend>PROXY_SECRET: a long random string shared with the frontendBREVO_API_KEY, MAIL_FROM, MAIL_FROM_NAME: password-reset email (see above). Optional for
the server to start, but without the first two nobody can reset a password.APP_ORIGIN and PROXY_SECRET.Frontend: Vercel with root directory frontend. Environment variables: BACKEND_ORIGIN
(e.g. https://<your-backend>.onrender.com) and the same PROXY_SECRET. frontend/middleware.js
proxies /api/*. frontend/vercel.json adds the SPA fallback and the security headers.
| owner |
| Manage the invite link |
POST /api/groups/:id/requests/:uid/{approve,decline} | owner | Decide on a join request |
GET /api/invites/:token, POST /api/invites/:token/request | you | Preview a tab / ask to join |