diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f16405..968b48e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 471e366..0c833c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 | @@ -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, diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index dfba71e..793dd7d 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -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 diff --git a/README.md b/README.md index 18204bf..e0ecc76 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 7072bfc..83089a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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.) @@ -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 @@ -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 diff --git a/app/api/demo/[action]/route.ts b/app/api/demo/[action]/route.ts index b460198..e832690 100644 --- a/app/api/demo/[action]/route.ts +++ b/app/api/demo/[action]/route.ts @@ -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({ @@ -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 }; } diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 1d1c0c9..b1748f3 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -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(); @@ -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) } }); +}); diff --git a/drizzle/0003_sessions_valid_after.sql b/drizzle/0003_sessions_valid_after.sql new file mode 100644 index 0000000..045829a --- /dev/null +++ b/drizzle/0003_sessions_valid_after.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "sessions_valid_after" timestamp with time zone; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..24f8497 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,457 @@ +{ + "id": "074c2fd2-5b84-454f-bdd0-1bf6991de9cd", + "prevId": "2043b2a0-d9c1-44e3-81cc-6529c780d49a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.buys": { + "name": "buys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_ref": { + "name": "quote_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "target_asset": { + "name": "target_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_bps": { + "name": "cost_bps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_in_usd": { + "name": "amount_in_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "amount_out": { + "name": "amount_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": false + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "near_tx_hash": { + "name": "near_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_tx_hash": { + "name": "destination_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "buys_user_idx": { + "name": "buys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "buys_user_id_users_id_fk": { + "name": "buys_user_id_users_id_fk", + "tableFrom": "buys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "buys_quote_ref_unique": { + "name": "buys_quote_ref_unique", + "nullsNotDistinct": false, + "columns": [ + "quote_ref" + ] + }, + "buys_deposit_address_unique": { + "name": "buys_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_vaults": { + "name": "demo_vaults", + "schema": "", + "columns": { + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "withdrawn": { + "name": "withdrawn", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + } + }, + "indexes": {}, + "foreignKeys": { + "demo_vaults_user_id_users_id_fk": { + "name": "demo_vaults_user_id_users_id_fk", + "tableFrom": "demo_vaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_attempts": { + "name": "funding_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_asset": { + "name": "origin_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "expected_out": { + "name": "expected_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "refund_to": { + "name": "refund_to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_deadline": { + "name": "quote_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_deposit'" + }, + "deposit_tx_hash": { + "name": "deposit_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_attempts_user_idx": { + "name": "funding_attempts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_attempts_user_id_users_id_fk": { + "name": "funding_attempts_user_id_users_id_fk", + "tableFrom": "funding_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "funding_attempts_deposit_address_unique": { + "name": "funding_attempts_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_demo": { + "name": "is_demo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions_valid_after": { + "name": "sessions_valid_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_near_account_id_unique": { + "name": "users_near_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "near_account_id" + ] + }, + "users_vault_account_id_unique": { + "name": "users_vault_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "vault_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 728f1ed..2a5f345 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1793865866986, "tag": "0002_demo_vaults", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1794128399884, + "tag": "0003_sessions_valid_after", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries/users.ts b/lib/db/queries/users.ts index d3dd0a2..d2be87d 100644 --- a/lib/db/queries/users.ts +++ b/lib/db/queries/users.ts @@ -48,3 +48,8 @@ export async function deleteStaleDemoUsers(db: Db, olderThan: Date): Promise { + await db.update(users).set({ sessionsValidAfter: at }).where(eq(users.id, userId)); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 08cd327..119649a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -14,6 +14,8 @@ export const users = pgTable("users", { nearAccountId: text("near_account_id").notNull().unique(), vaultAccountId: text("vault_account_id").unique(), isDemo: boolean("is_demo").notNull().default(false), + /** Sessions issued before this time are rejected; set on sign-out (K13.4). */ + sessionsValidAfter: ts("sessions_valid_after"), createdAt: ts("created_at").notNull().defaultNow(), }); diff --git a/lib/server/context.ts b/lib/server/context.ts index e7c2021..e863005 100644 --- a/lib/server/context.ts +++ b/lib/server/context.ts @@ -8,7 +8,7 @@ import { getUserById } from "../db/queries/users"; import type { User } from "../db/schema"; import { servicesFor, type Services } from "./adapters"; import { HttpError } from "./http"; -import { requireSession, type Session } from "./session"; +import { requireSession, sessionRevoked, type Session } from "./session"; export interface UserContext { session: Session; @@ -20,7 +20,7 @@ export async function requireUser(req: Request): Promise { const session = requireSession(req); const services = await servicesFor(session); const user = await getUserById(services.db, session.userId); - if (!user || user.isDemo !== session.demo) throw new HttpError(401, "Sign in again"); + if (!user || user.isDemo !== session.demo || sessionRevoked(session, user.sessionsValidAfter)) throw new HttpError(401, "Sign in again"); return { session, services, user }; } diff --git a/lib/server/routes.test.ts b/lib/server/routes.test.ts index 3ebe7a1..81b0982 100644 --- a/lib/server/routes.test.ts +++ b/lib/server/routes.test.ts @@ -158,9 +158,28 @@ describe("session (K4.1)", () => { }); it("signs out", async () => { - const res = await sessionRoute.DELETE(); + const res = await sessionRoute.DELETE(req("/api/session", { method: "DELETE" })); expect(res.headers.get("set-cookie")).toContain("Max-Age=0"); }); + + it("signing out revokes the session everywhere, even if the cookie was copied (K13.4)", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-11-08T10:00:00Z")); + const stolen = await signIn(); + expect((await vaultRoute.GET(req("/api/vault", { cookie: stolen }))).status).toBe(200); + + vi.setSystemTime(new Date("2026-11-08T10:05:00Z")); + expect((await sessionRoute.DELETE(req("/api/session", { method: "DELETE", cookie: stolen }))).status).toBe(200); + expect((await vaultRoute.GET(req("/api/vault", { cookie: stolen }))).status).toBe(401); + + vi.setSystemTime(new Date("2026-11-08T10:06:00Z")); + const fresh = await signIn(); + expect((await vaultRoute.GET(req("/api/vault", { cookie: fresh }))).status).toBe(200); + } finally { + vi.useRealTimers(); + } + }); }); describe("onboard + vault (K4.2, K4.3)", () => { diff --git a/lib/server/session.ts b/lib/server/session.ts index cccf5c7..5464bdd 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -19,6 +19,8 @@ export interface Session { userId: string; accountId: string; demo: boolean; + /** Issued at, unix seconds. Older tokens have none: `issuedAtS` derives it from `exp`. */ + iat?: number; exp: number; } @@ -92,8 +94,18 @@ export function requireSession(req: Request): Session { return s; } -export function sessionCookie(data: Omit, now = Date.now()): string { - return cookie(SESSION_COOKIE, signToken({ ...data, exp: Math.floor(now / 1000) + SESSION_TTL_S }), SESSION_TTL_S); +export function sessionCookie(data: Omit, now = Date.now()): string { + const iat = Math.floor(now / 1000); + return cookie(SESSION_COOKIE, signToken({ ...data, iat, exp: iat + SESSION_TTL_S }), SESSION_TTL_S); +} + +function issuedAtS(s: Session): number { + return s.iat ?? s.exp - SESSION_TTL_S; +} + +/** True when the user signed out (or was signed out) after this session was issued (K13.4). */ +export function sessionRevoked(session: Session, sessionsValidAfter: Date | null): boolean { + return sessionsValidAfter !== null && issuedAtS(session) < Math.floor(sessionsValidAfter.getTime() / 1000); } /** The NEP-413 `recipient`: this app's host, so a signature for another site can't be replayed here. */