Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/workflows/deploy-convex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
paths:
- 'convex/**'
- 'release/authentication-cutover.json'
- 'scripts/check-authentication-cutover.mjs'
- '.github/workflows/deploy-convex.yaml'
workflow_dispatch:

jobs:
Expand All @@ -18,6 +21,9 @@ jobs:
with:
node-version: '20'

- name: Require authenticated-client rollout evidence
run: node scripts/check-authentication-cutover.mjs
Comment thread
pullfrog[bot] marked this conversation as resolved.

- name: Install dependencies
run: npm ci

Expand Down
16 changes: 15 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ API keys are long-lived credentials scoped to specific actions. Mint one with a
JWT session (see [Agent API v1](#agent-api-v1)). When an `X-API-Key` header is
present it takes precedence over the `Authorization` header. Keys carry scopes
(`lists:read`, `items:read`, `items:write`); a request missing the required
scope returns `401`. JWT sessions have full access.
scope returns `403`. JWT sessions have full access.

The authenticated-boundary cutover distinguishes `401` (missing, invalid, expired,
or revoked credentials) from `403` (valid credentials without the required scope
or resource access). API-key consumers that previously treated every denial as
`401` must handle both. Missing and inaccessible resources behind the HTTP write boundary return the
same `403` response to avoid disclosing private resource existence.

## Endpoints

Expand Down Expand Up @@ -334,3 +340,11 @@ await fetch(`${BASE_URL}/api/agent/items/${itemId}`, {
body: JSON.stringify({ checked: true })
});
```

### Direct Convex clients

Authenticated direct operations require `authToken` (the JWT from login) or an appropriately scoped `apiKey`. Browser/native clients first call `actorSession.establish({ authToken })` so logout and expiry invalidate reactive subscriptions. HTTP clients continue sending Bearer/cookie JWT or `X-API-Key`; the HTTP adapter establishes existing valid sessions automatically.

Do not supply acting DIDs. Ownership, attribution and legacy-account access are resolved from authenticated server records. Old optional identity fields are compatibility checks only and never grant access. Anonymous access is limited to explicitly public resources with active publications; shared writes require authentication.

See [authentication rollout](docs/authentication-rollout.md) for the required deployed-version confirmation and coordinated client/backend cutover.
12 changes: 12 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type * as activity from "../activity.js";
import type * as activityHttp from "../activityHttp.js";
import type * as actorSession from "../actorSession.js";
import type * as adminGrants from "../adminGrants.js";
import type * as agentReadHttp from "../agentReadHttp.js";
import type * as apiKeys from "../apiKeys.js";
Expand Down Expand Up @@ -44,8 +45,12 @@ import type * as lib_actor from "../lib/actor.js";
import type * as lib_analytics from "../lib/analytics.js";
import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js";
import type * as lib_auth from "../lib/auth.js";
import type * as lib_authError from "../lib/authError.js";
import type * as lib_authUser from "../lib/authUser.js";
import type * as lib_authenticated from "../lib/authenticated.js";
import type * as lib_bucket from "../lib/bucket.js";
import type * as lib_bucketKeys from "../lib/bucketKeys.js";
import type * as lib_clientAuth from "../lib/clientAuth.js";
import type * as lib_didLogAuth from "../lib/didLogAuth.js";
import type * as lib_httpResponses from "../lib/httpResponses.js";
import type * as lib_itemCategories from "../lib/itemCategories.js";
Expand All @@ -54,6 +59,7 @@ import type * as lib_legacyList from "../lib/legacyList.js";
import type * as lib_listEnvelope from "../lib/listEnvelope.js";
import type * as lib_observability from "../lib/observability.js";
import type * as lib_permissions from "../lib/permissions.js";
import type * as lib_session from "../lib/session.js";
import type * as lib_turnkeyClient from "../lib/turnkeyClient.js";
import type * as lib_turnkeySigner from "../lib/turnkeySigner.js";
import type * as lists from "../lists.js";
Expand Down Expand Up @@ -93,6 +99,7 @@ import type {
declare const fullApi: ApiFromModules<{
activity: typeof activity;
activityHttp: typeof activityHttp;
actorSession: typeof actorSession;
adminGrants: typeof adminGrants;
agentReadHttp: typeof agentReadHttp;
apiKeys: typeof apiKeys;
Expand Down Expand Up @@ -127,8 +134,12 @@ declare const fullApi: ApiFromModules<{
"lib/analytics": typeof lib_analytics;
"lib/apiKeyHelpers": typeof lib_apiKeyHelpers;
"lib/auth": typeof lib_auth;
"lib/authError": typeof lib_authError;
"lib/authUser": typeof lib_authUser;
"lib/authenticated": typeof lib_authenticated;
"lib/bucket": typeof lib_bucket;
"lib/bucketKeys": typeof lib_bucketKeys;
"lib/clientAuth": typeof lib_clientAuth;
"lib/didLogAuth": typeof lib_didLogAuth;
"lib/httpResponses": typeof lib_httpResponses;
"lib/itemCategories": typeof lib_itemCategories;
Expand All @@ -137,6 +148,7 @@ declare const fullApi: ApiFromModules<{
"lib/listEnvelope": typeof lib_listEnvelope;
"lib/observability": typeof lib_observability;
"lib/permissions": typeof lib_permissions;
"lib/session": typeof lib_session;
"lib/turnkeyClient": typeof lib_turnkeyClient;
"lib/turnkeySigner": typeof lib_turnkeySigner;
lists: typeof lists;
Expand Down
17 changes: 10 additions & 7 deletions convex/activity.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { actorMutation, actorQuery } from "./lib/authenticated";
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";

import { canUserEditList } from "./lib/permissions";

export const recordActivity = mutation({
export const { public: recordActivity, internal: recordActivityInternal } = actorMutation({
resources: args => ({ lists: [args.listId], items: [args.itemId] }),
scope: "items:write",
args: {
listId: v.id("lists"),
itemId: v.optional(v.id("items")),
actorDid: v.string(),
legacyDid: v.optional(v.string()),
type: v.union(
v.literal("item_assigned"),
v.literal("item_unassigned"),
Expand All @@ -23,21 +24,23 @@ export const recordActivity = mutation({
})),
},
handler: async (ctx, args) => {
const canEdit = await canUserEditList(ctx, args.listId, args.actorDid, args.legacyDid);
const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid);
if (!canEdit) throw new Error("Not authorized to write activity");

return await ctx.db.insert("activities", {
listId: args.listId,
itemId: args.itemId,
actorDid: args.actorDid,
actorDid: ctx.actor.did,
type: args.type,
metadata: args.metadata,
createdAt: Date.now(),
});
},
});

export const getListActivity = query({
export const { public: getListActivity, internal: getListActivityInternal } = actorQuery({
resources: args => ({ lists: [args.listId] }),
scope: "items:read",
args: {
listId: v.id("lists"),
limit: v.optional(v.number()),
Expand Down
14 changes: 6 additions & 8 deletions convex/activityHttp.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { AuthError, unauthorizedResponseWithCors } from "./lib/auth";
import { requireAuthenticatedUser } from "./lib/authUser";
import { jsonResponse, errorResponse } from "./lib/httpResponses";
import { authenticatedRequest } from "./lib/actor";
import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses";

export const getListActivity = httpAction(async (ctx, request) => {
try {
await requireAuthenticatedUser(ctx, request);
const body = await request.json();
const { listId, limit } = body as { listId: string; limit?: number };
if (!listId) return errorResponse(request, "listId is required");

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activities = await ctx.runQuery((api as any).activity.getListActivity, {
const activities = await ctx.runQuery(internal.activity.getListActivityInternal, {
...await authenticatedRequest(ctx, request),
listId: listId as Id<"lists">,
limit,
});

return jsonResponse(request, { activities });
} catch (error) {
if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message);
return errorResponse(request, error instanceof Error ? error.message : "Failed to get activity", 500);
return handlerErrorResponse(request, error, "Failed to get activity");
}
});
101 changes: 101 additions & 0 deletions convex/actorSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { authorizeResources } from "./lib/permissions";
import { requireSession } from "./lib/session";
import { v } from "convex/values";
import { internalQuery, internalMutation, mutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { authenticate, type ResolvedActor } from "./lib/actor";
import { verifyAuthToken } from "./lib/jwt";
import { hashApiKey } from "./lib/apiKeyHelpers";
import { AuthError } from "./lib/auth";

export const resolve = internalQuery({
args: { authToken: v.optional(v.string()), apiKey: v.optional(v.string()) },
handler: (ctx, args): Promise<ResolvedActor> => authenticate(ctx, args),
});

// Establishing a record proves possession of a signed session, never a DID.
const establishOperation = {
args: { authToken: v.string() },
handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => {
const session = await verifyAuthToken(args.authToken).catch(() => {
throw new AuthError("Invalid or expired token", "INVALID_TOKEN");
});
const tokenHash = await hashApiKey(args.authToken);
const existing = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first();
if (existing) {
if (existing.revokedAt !== undefined) throw new AuthError("Invalid or expired token", "INVALID_TOKEN");
return;
}
const user = await ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", session.turnkeySubOrgId)).first();
if (!user) throw new AuthError("User not found", "UNAUTHORIZED");
const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt });
await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id });
},
};
export const establish = mutation(establishOperation);
export const establishInternal = internalMutation(establishOperation);
export const expire = internalMutation({
args: { id: v.id("accessSessions") },
handler: async (ctx, { id }) => {
const record = await ctx.db.get(id);
if (record && record.expiresAt <= Date.now()) await ctx.db.delete(id);
},
});

// Recover records whose individual expiry callback did not complete. Revoked
// records remain until token expiry so they cannot be established again.
export const cleanupExpiredSessions = internalMutation({
args: {},
handler: async (ctx) => {
const expired = await ctx.db
.query("accessSessions")
.withIndex("by_expires_at", q => q.lte("expiresAt", Date.now()))
.take(100);
for (const session of expired) await ctx.db.delete(session._id);
return expired.length;
},
});

const revokeOperation = {
args: { authToken: v.string() },
handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => {
const tokenHash = await hashApiKey(args.authToken);
const record = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first();
if (record) {
await ctx.db.patch(record._id, { revokedAt: Date.now() });
} else {
// A pre-rollout JWT can be logged out before its first authenticated call.
const session = await verifyAuthToken(args.authToken);
const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt, revokedAt: Date.now() });
await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id });
}
},
};
export const revoke = mutation(revokeOperation);
export const revokeInternal = internalMutation(revokeOperation);

export const identity = internalQuery({
args: { authToken: v.string() },
handler: (ctx, args) => requireSession(ctx, args.authToken),
});

export const authorize = internalQuery({
args: {
authToken: v.optional(v.string()), apiKey: v.optional(v.string()),
resources: v.object({
lists: v.optional(v.array(v.union(v.id("lists"), v.null()))),
items: v.optional(v.array(v.union(v.id("items"), v.null()))),
anchors: v.optional(v.array(v.union(v.id("bitcoinAnchors"), v.null()))),
accounts: v.optional(v.array(v.union(v.id("users"), v.null()))),
}),
},
handler: async (ctx, args): Promise<void> => {
const actor = await authenticate(ctx, args);
await authorizeResources(ctx, actor, {
lists: args.resources.lists?.filter(id => id !== null),
items: args.resources.items?.filter(id => id !== null),
anchors: args.resources.anchors?.filter(id => id !== null),
accounts: args.resources.accounts?.filter(id => id !== null),
});
},
});
33 changes: 6 additions & 27 deletions convex/agentReadHttp.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/**
* HTTP read endpoints for agents.
*
* These GET handlers authenticate via resolveActor() (JWT session or agent
* API key) and require read scopes. They wrap existing queries without changing
* their logic, and resolve to a single current DID (no legacy/wallet threading).
*/
/** HTTP adapter; authentication and authorization run in the shared operation. */

import { httpAction } from "./_generated/server";
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { AuthError, unauthorizedResponseWithCors } from "./lib/auth";
import { resolveActor, requireScope } from "./lib/actor";
import { authenticatedRequest } from "./lib/actor";
import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses";

/**
Expand All @@ -21,18 +14,11 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes
*/
export const getLists = httpAction(async (ctx, request) => {
try {
const actor = await resolveActor(ctx, request);
requireScope(actor, "lists:read");

const lists = await ctx.runQuery(api.lists.getUserLists, {
userDid: actor.did,
legacyDid: actor.legacyDid,
const lists = await ctx.runQuery(internal.lists.getUserListsInternal, {
...await authenticatedRequest(ctx, request),
});
return jsonResponse(request, { lists });
} catch (error) {
if (error instanceof AuthError) {
return unauthorizedResponseWithCors(request, error.message);
}
console.error("[agentReadHttp] getLists error:", error);
return handlerErrorResponse(
request,
Expand All @@ -51,9 +37,6 @@ export const getLists = httpAction(async (ctx, request) => {
*/
export const getListWithItems = httpAction(async (ctx, request) => {
try {
const actor = await resolveActor(ctx, request);
requireScope(actor, "items:read");

const listId = new URL(request.url).searchParams.get("listId");
if (!listId) {
return errorResponse(request, "listId query parameter is required");
Expand All @@ -63,17 +46,13 @@ export const getListWithItems = httpAction(async (ctx, request) => {
// caller may not view it, so an items:read key can't read arbitrary lists.
const result = await ctx.runQuery(internal.lists.getListWithItemsForViewer, {
listId: listId as Id<"lists">,
viewerDid: actor.did,
legacyDid: actor.legacyDid,
...await authenticatedRequest(ctx, request),
});
if (!result) {
return errorResponse(request, "List not found", 404);
}
return jsonResponse(request, result);
} catch (error) {
if (error instanceof AuthError) {
return unauthorizedResponseWithCors(request, error.message);
}
console.error("[agentReadHttp] getListWithItems error:", error);
return handlerErrorResponse(
request,
Expand Down
6 changes: 3 additions & 3 deletions convex/apiKeysHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { httpAction } from "./_generated/server";
import type { ActionCtx } from "./_generated/server";
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import {
requireAuth,
Expand All @@ -35,8 +35,8 @@ async function requireUserDid(
ctx: ActionCtx,
request: Request
): Promise<string | null> {
const auth = await requireAuth(request);
const user = (await ctx.runQuery(api.auth.getUserByTurnkeyId, {
const auth = await requireAuth(ctx, request);
const user = (await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, {
turnkeySubOrgId: auth.turnkeySubOrgId,
})) as UserInfo;
return user?.did ?? null;
Expand Down
Loading
Loading