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: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,15 @@ jobs:
workspaces: contracts
# The sandbox tests build the contracts with `cargo near` (near-workspaces compile_project).
- name: Install cargo-near 0.22.0
run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.22.0/cargo-near-installer.sh | sh
# GitHub's release download sometimes answers 504: retry, then build from crates.io instead of failing.
run: |
for attempt in 1 2 3 4 5; do
if curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.22.0/cargo-near-installer.sh | sh; then exit 0; fi
echo "cargo-near download failed (attempt $attempt of 5), retrying in 20 s"
sleep 20
done
echo "GitHub release download unavailable; building cargo-near 0.22.0 from crates.io instead"
cargo install cargo-near --version 0.22.0 --locked
- run: cargo check --workspace --all-targets
- run: cargo test --workspace

Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most
|---|---|---|
| `GET /api/session` | public | Challenge: 32-byte nonce + message + recipient (app host), in a signed 5-min cookie |
| `POST /api/session` | NEP-413 | Verify signature (full-access key, matching nonce/message/recipient) → upsert user → HMAC-signed HTTP-only session cookie (7 days) |
| `DELETE /api/session` | – | Sign out |
| `DELETE /api/session` | session | Sign out everywhere: sets `users.sessions_valid_after`, so every session issued before now is rejected (K13.4) |
| `POST /api/onboard` | session | Keeper `create_vault_for(owner)` (once) |
| `GET /api/vault` | session | `get_state()` + `ft_balance_of` + next run |
| `GET /api/tokens` | public | Cached token list |
Expand Down Expand Up @@ -206,7 +206,7 @@ Runner: a loop never overlaps itself, errors are logged and the loop continues,

```sql
users (id uuid pk, near_account_id text unique not null, vault_account_id text unique,
is_demo boolean default false, created_at timestamptz default now())
is_demo boolean default false, sessions_valid_after timestamptz, created_at timestamptz default now())

funding_attempts (id uuid pk, user_id uuid fk, origin_asset text not null, amount_in numeric(78,0) not null,
expected_out numeric(78,0) not null, refund_to text not null, deposit_address text unique not null,
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15.
| no route | – | no_quote |

## Data model (amounts numeric(78,0))
`users` (near_account_id, vault_account_id, is_demo) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status)
`users` (near_account_id, vault_account_id, is_demo, sessions_valid_after) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status)
On-chain vault = truth for plan + balance.

## API
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Other protections: real quotes are refused unless their signature verifies (fail

Being honest about what is and isn't live:

- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent.
- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent; signing out now ends the session on every device, so a copied session cookie stops working.

- **Contracts are built and tested but not deployed yet** (ROADMAP K1.13). Real mode needs deployed contracts, funded keeper/factory accounts and a 1Click partner JWT.
- **Quote signatures:** real 1Click quotes work without a partner key, but `verifyQuoteSignature()` in the newest SDK (0.1.26) returns false on dry and real quotes (tested 2026-11-05). Real mode refuses unverified quotes, so real funding and buys stay closed until 1Click confirms the current signing key or hash (ROADMAP K3.5). The demo is not affected.
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
| 10. README & public repo | Nov 12–13 | 100% | ✅ done (hosted URL added) |
| 11. Launch: video, post, submission | Nov 13–14 | 57% | in progress (video, post and submission need the human) |
| 12. After submission | Nov 14–25 | 0% | not started |
| 13. Launch review fixes | Nov 8–13 | 22% | in progress (pre-launch security and reliability review) |
| 13. Launch review fixes | Nov 8–13 | 33% | in progress (pre-launch security and reliability review) |
| **Overall (MVP)** | | **~77%** | |

Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 15 rows (D + phases 0–13; Phase 13 was added on 2026-11-08, which lowered the overall %). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.)
Expand Down Expand Up @@ -213,7 +213,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au
- [x] **K13.1** [#127](https://github.com/MFrancis-dev/stack/issues/127) Expire buys whose deposit never reaches 1Click · 2026-11-08: `pollBuys` marks a buy `expired` when 1Click still shows `PENDING_DEPOSIT` 30 min after it (quote deadline 10 min + margin); buys 1Click has seen are never expired; 3 tests
- [x] **K13.2** [#128](https://github.com/MFrancis-dev/stack/issues/128) Unclear `execute_buy` errors must not be recorded as failed (a timeout after broadcast showed "Nothing spent" while USDC left) · 2026-11-08: the NEAR adapter raises `BuyOutcomeUnknownError` unless the error is a clear failure (contract refusal, failed action, rejected transaction); the scheduler keeps that buy `sent` with a note and alerts; K13.1's expiry settles it if nothing arrived; 3 tests
- [ ] **K13.3** [#129](https://github.com/MFrancis-dev/stack/issues/129) ⛔ Vault creation spends the factory's NEAR for anyone who signs in (~1.5 NEAR per vault): owner pays storage, or create after the first deposit
- [ ] **K13.4** [#130](https://github.com/MFrancis-dev/stack/issues/130) Make sessions revocable (sign-out only cleared the cookie)
- [x] **K13.4** [#130](https://github.com/MFrancis-dev/stack/issues/130) Make sessions revocable (sign-out only cleared the cookie) · 2026-11-08: sessions carry an issued-at time; sign-out sets `users.sessions_valid_after` (migration 0003) and every older session is rejected, real and demo; test signs in, signs out, checks the copied cookie fails and a new sign-in works
- [ ] **K13.5** [#131](https://github.com/MFrancis-dev/stack/issues/131) Keep preview and development off the production database
- [ ] **K13.6** [#132](https://github.com/MFrancis-dev/stack/issues/132) Cap demo starts and fast-forwards in the database
- [ ] **K13.7** [#133](https://github.com/MFrancis-dev/stack/issues/133) Rate limits shared across serverless instances
Expand All @@ -234,6 +234,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au
## Update log
One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks`

- 2026-11-08 · agent · K13.4 revocable sessions: issued-at in the token, sign-out revokes every older session (`sessions_valid_after`, migration 0003); README status, ARCHITECTURE §4.1/§5, ESSENTIALS updated · K13.4
- 2026-11-08 · agent · K13.2 unclear `execute_buy` results stay in flight (`BuyOutcomeUnknownError`, `isClearTxFailure`) instead of being marked failed; README status and ARCHITECTURE §4.4 updated · K13.2
- 2026-11-08 · agent · Pre-launch review: new Phase 13 (9 tasks, issues #127–#135); K13.1 buys whose deposit never reaches 1Click expire after 30 min; README status updated · K13.1
- 2026-11-06 · agent · Added an alternative demo video script ("One saver, one month") · K11.1
Expand Down
4 changes: 2 additions & 2 deletions app/api/demo/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { applyOwnerTx, DemoRuleError } from "@/lib/near/demo";
import { getServices } from "@/lib/server/adapters";
import { clientIp, handle, HttpError, json, parseBody } from "@/lib/server/http";
import { rateLimit } from "@/lib/server/rateLimit";
import { requireSession, sessionCookie } from "@/lib/server/session";
import { requireSession, sessionCookie, sessionRevoked } from "@/lib/server/session";

const ownerTxInput = z.object({
tx: z.object({
Expand All @@ -33,7 +33,7 @@ async function demoUser(req: Request) {
if (!session.demo) throw new HttpError(403, "Only available in demo mode");
const { db } = await getServices();
const user = await getUserById(db, session.userId);
if (!user?.isDemo) throw new HttpError(401, "Demo expired, start it again");
if (!user?.isDemo || sessionRevoked(session, user.sessionsValidAfter)) throw new HttpError(401, "Demo expired, start it again");
return { db, user };
}

Expand Down
11 changes: 8 additions & 3 deletions app/api/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
*/

import { sessionInput } from "@/lib/core/schemas";
import { upsertUser } from "@/lib/db/queries/users";
import { revokeSessions, upsertUser } from "@/lib/db/queries/users";
import { verifySignIn } from "@/lib/near/nep413";
import { getServices, requireNear } from "@/lib/server/adapters";
import { handle, HttpError, json, parseBody } from "@/lib/server/http";
import { CHALLENGE_COOKIE, clearCookie, newChallenge, readChallenge, SESSION_COOKIE, sessionCookie } from "@/lib/server/session";
import { CHALLENGE_COOKIE, clearCookie, getSession, newChallenge, readChallenge, SESSION_COOKIE, sessionCookie } from "@/lib/server/session";

export const GET = handle(async () => {
const { challenge, setCookie } = newChallenge();
Expand Down Expand Up @@ -40,4 +40,9 @@ export const POST = handle(async (req: Request) => {
return json({ accountId: user.nearAccountId, vaultAccountId: user.vaultAccountId }, { headers });
});

export const DELETE = handle(async () => json({ ok: true }, { headers: { "set-cookie": clearCookie(SESSION_COOKIE) } }));
/** Signs out on every device: sessions issued before now are rejected from here on (K13.4). */
export const DELETE = handle(async (req: Request) => {
const session = getSession(req);
if (session) await revokeSessions((await getServices()).db, session.userId, new Date());
return json({ ok: true }, { headers: { "set-cookie": clearCookie(SESSION_COOKIE) } });
});
1 change: 1 addition & 0 deletions drizzle/0003_sessions_valid_after.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "sessions_valid_after" timestamp with time zone;
Loading
Loading