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
14 changes: 11 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ with a key.
- response: `{ "lists": [...] }`
- `GET /api/v1/lists/items?listId=<id>` — get a list and its items (`items:read`).
- response: `{ "list": {...} | null, "items": [...] }`
- `GET /api/v1/action-records?itemId=<id>` — action records for an item (`items:read`).
- `GET /api/v1/action-records?listId=<id>` — a list's own records, e.g. created/renamed (`lists:read`).
- response: `{ "records": [...] }`, newest first, at most 50. Each record carries the exact
signed `payload`, its `digest`, `status` (`pending` | `signed` | `failed` | `unsigned`) and,
once signed, `signature`, `publicKeyMultibase` and `verificationMethod`. See
[action records](docs/action-records.md) for the payload and how to verify it independently.

### Writes (JWT or `X-API-Key`)

Expand All @@ -247,9 +253,11 @@ The existing mutation endpoints now also accept an `X-API-Key`. All require the
- `POST /api/items/add`, `POST /api/items/check`, `POST /api/items/uncheck`,
`POST /api/items/remove`, `POST /api/items/reorder`

Item writes made via an API key are attributed to the key's owner DID (or its
`agentDid`, if set). The item-authorship credential remains an unsigned
placeholder — cryptographic signing is out of scope for now.
Writes made via an API key are authorized by the key's owner account. Creating,
completing and reopening items and creating or renaming lists each write an
[action record](docs/action-records.md) naming that account and the API key row
used. Records are not signed yet. A
caller-supplied DID or a key's `agentDid` never establishes who acted.

### Scopes

Expand Down
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @module
*/

import type * as actionRecords from "../actionRecords.js";
import type * as activity from "../activity.js";
import type * as activityHttp from "../activityHttp.js";
import type * as actorSession from "../actorSession.js";
Expand Down Expand Up @@ -44,6 +45,7 @@ import type * as invitations from "../invitations.js";
import type * as invitationMail from "../invitationMail.js";
import type * as items from "../items.js";
import type * as itemsHttp from "../itemsHttp.js";
import type * as lib_actionRecords from "../lib/actionRecords.js";
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";
Expand Down Expand Up @@ -112,6 +114,7 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
actionRecords: typeof actionRecords;
activity: typeof activity;
activityHttp: typeof activityHttp;
actorSession: typeof actorSession;
Expand Down Expand Up @@ -148,6 +151,7 @@ declare const fullApi: ApiFromModules<{
invitationMail: typeof invitationMail;
items: typeof items;
itemsHttp: typeof itemsHttp;
"lib/actionRecords": typeof lib_actionRecords;
"lib/actor": typeof lib_actor;
"lib/analytics": typeof lib_analytics;
"lib/apiKeyHelpers": typeof lib_apiKeyHelpers;
Expand Down
56 changes: 56 additions & 0 deletions convex/actionRecords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Action records (#237): reads for provenance and independent verification.
* Records are written by lib/actionRecords.ts recordActions. No signer is wired
* up yet; see docs/action-records.md.
*/
import { v } from "convex/values";
import { internalMutation } from "./_generated/server";
import type { Doc } from "./_generated/dataModel";
import { actorQuery } from "./lib/authenticated";
import { deleteActionRecords } from "./lib/actionRecords";

const MAX_RECORDS_PER_READ = 50;

/** Everything shared/actionRecord.ts verifyActionRecord needs, and nothing internal. */
function evidence(record: Doc<"actionRecords">) {
return {
_id: record._id,
payload: record.payload,
digest: record.digest,
status: record.status,
listId: record.listId,
itemId: record.itemId,
ownerUserId: record.ownerUserId,
signature: record.signature,
verificationMethod: record.verificationMethod,
publicKeyMultibase: record.publicKeyMultibase,
signedAt: record.signedAt,
error: record.error,
};
}

/** Newest action records for one item. Same read access as the item itself. */
export const { public: getItemActionRecords, internal: getItemActionRecordsInternal } = actorQuery({
resources: args => ({ items: [args.itemId] }),
scope: "items:read",
args: { itemId: v.id("items") },
handler: async (ctx, args) => (await ctx.db.query("actionRecords")
.withIndex("by_item", q => q.eq("itemId", args.itemId))
.order("desc").take(MAX_RECORDS_PER_READ)).map(evidence),
});

/** Newest list-level records (created, renamed). Item records are read per item. */
export const { public: getListActionRecords, internal: getListActionRecordsInternal } = actorQuery({
resources: args => ({ lists: [args.listId] }),
scope: "lists:read",
args: { listId: v.id("lists") },
handler: async (ctx, args) => (await ctx.db.query("actionRecords")
.withIndex("by_list", q => q.eq("listId", args.listId).eq("itemId", undefined))
.order("desc").take(MAX_RECORDS_PER_READ)).map(evidence),
});

/** Continues deleteActionRecords for subjects with more records than one batch. */
export const deleteRemaining = internalMutation({
args: v.union(v.object({ listId: v.id("lists") }), v.object({ itemId: v.id("items") })),
handler: async (ctx, target) => deleteActionRecords(ctx, target),
});
38 changes: 38 additions & 0 deletions convex/agentReadHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,41 @@ export const getListWithItems = httpAction(async (ctx, request) => {
);
}
});

/**
* GET /api/v1/action-records?itemId=<id> (requires "items:read")
* GET /api/v1/action-records?listId=<id> (requires "lists:read"; list-level records)
*
* Action records in the shape shared/actionRecord.ts verifyActionRecord
* takes, so a caller can verify them without trusting this server.
* Response: { "records": [...] }
*/
export const getActionRecords = httpAction(async (ctx, request) => {
try {
const params = new URL(request.url).searchParams;
const itemId = params.get("itemId");
const listId = params.get("listId");
if (!itemId === !listId) {
return errorResponse(request, "Provide exactly one of itemId or listId");
}

const credentials = await authenticatedRequest(ctx, request);
const records = itemId
? await ctx.runQuery(internal.actionRecords.getItemActionRecordsInternal, {
itemId: itemId as Id<"items">,
...credentials,
})
: await ctx.runQuery(internal.actionRecords.getListActionRecordsInternal, {
listId: listId as Id<"lists">,
...credentials,
});
return jsonResponse(request, { records });
} catch (error) {
console.error("[agentReadHttp] getActionRecords error:", error);
return handlerErrorResponse(
request,
error,
"Failed to get action records"
);
}
});
10 changes: 8 additions & 2 deletions convex/didResources.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resourceUnavailable } from "./lib/authError";
import { actorMutation } from "./lib/authenticated";
import { itemCompleted, itemReopened, recordActions } from "./lib/actionRecords";
/**
* Queries for serving list resources publicly.
*
Expand Down Expand Up @@ -127,12 +128,15 @@ export const { public: checkSharedItem, internal: checkSharedItemInternal } = ac
throw resourceUnavailable();
}

const now = Date.now();
await ctx.db.patch(args.itemId, {
checked: true,
checkedByDid: ctx.actor.did,
checkedAt: Date.now(),
updatedAt: Date.now(),
checkedAt: now,
updatedAt: now,
});
const list = await ctx.db.get(item.listId);
await recordActions(ctx, [itemCompleted(list ?? { _id: item.listId }, item, now)]);

return { ok: true };
},
Expand Down Expand Up @@ -160,6 +164,8 @@ export const { public: uncheckSharedItem, internal: uncheckSharedItemInternal }
checkedAt: undefined,
updatedAt: Date.now(),
});
const list = await ctx.db.get(item.listId);
await recordActions(ctx, [itemReopened(list ?? { _id: item.listId }, item)]);

return { ok: true };
},
Expand Down
4 changes: 3 additions & 1 deletion convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { getListActivity as getListActivityHttp } from "./activityHttp";
import { stripeWebhook, createCheckout, createPortal, getSubscription } from "./billingHttp";
import { resolveSiteHost, resolveSiteAsset } from "./sitesHttp";
import { createApiKey, listApiKeys, revokeApiKey } from "./apiKeysHttp";
import { getLists, getListWithItems } from "./agentReadHttp";
import { getActionRecords, getLists, getListWithItems } from "./agentReadHttp";
const RATE_LIMITS = {
initiate: { windowMs: 60000, maxAttempts: 5 },
verify: { windowMs: 60000, maxAttempts: 5 },
Expand Down Expand Up @@ -442,6 +442,8 @@ http.route({ path: "/api/v1/lists", method: "GET", handler: getLists });
http.route({ path: "/api/v1/lists", method: "OPTIONS", handler: corsHandler });
http.route({ path: "/api/v1/lists/items", method: "GET", handler: getListWithItems });
http.route({ path: "/api/v1/lists/items", method: "OPTIONS", handler: corsHandler });
http.route({ path: "/api/v1/action-records", method: "GET", handler: getActionRecords });
http.route({ path: "/api/v1/action-records", method: "OPTIONS", handler: corsHandler });

// ============================================================================
// Health check endpoint (public, no auth)
Expand Down
Loading
Loading