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
46 changes: 5 additions & 41 deletions convex/activity.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,9 @@
import { resourceUnavailable } from "./lib/authError";
import { actorMutation, actorQuery } from "./lib/authenticated";
import { actorQuery } from "./lib/authenticated";
import { withoutCredential } from "./lib/actor";
import { v } from "convex/values";

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

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")),
type: v.union(
v.literal("item_assigned"),
v.literal("item_unassigned"),
v.literal("presence_heartbeat"),
v.literal("presence_offline"),
v.literal("item_updated"),
v.literal("list_updated")
),
metadata: v.optional(v.object({
assigneeDid: v.optional(v.string()),
status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))),
note: v.optional(v.string()),
})),
},
handler: async (ctx, args) => {
const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid);
if (!canEdit) throw new Error("Not authorized to write activity");

if (args.itemId && (await ctx.db.get(args.itemId))?.listId !== args.listId) throw resourceUnavailable();

return await ctx.db.insert("activities", {
listId: args.listId,
itemId: args.itemId,
actorDid: ctx.actor.did,
type: args.type,
metadata: args.metadata,
createdAt: Date.now(),
});
},
});
// Activity rows are written only by the server operations they describe; a public
// writer let editors fabricate assignment/reconciliation history.

export const { public: getListActivity, internal: getListActivityInternal } = actorQuery({
resources: args => ({ lists: [args.listId] }),
Expand All @@ -55,6 +19,6 @@ export const { public: getListActivity, internal: getListActivityInternal } = ac
.collect();

const sorted = events.sort((a, b) => b.createdAt - a.createdAt);
return sorted.slice(0, Math.max(1, Math.min(args.limit ?? 50, 200)));
return sorted.slice(0, Math.max(1, Math.min(args.limit ?? 50, 200))).map(withoutCredential);
},
});
9 changes: 8 additions & 1 deletion convex/billingHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { requireAuth } from "./lib/auth";
import { jsonResponse, errorResponse } from "./lib/httpResponses";
import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses";
import { authErrorData } from "./lib/authError";
import type { Id } from "./_generated/dataModel";

/**
Expand Down Expand Up @@ -72,6 +73,8 @@ export const createCheckout = httpAction(async (ctx, request) => {

return jsonResponse(request, { url });
} catch (err) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed");
console.error("[createCheckout] error:", err);
return errorResponse(request, err instanceof Error ? err.message : "Failed", 500);
}
Expand All @@ -98,6 +101,8 @@ export const createPortal = httpAction(async (ctx, request) => {

return jsonResponse(request, { url });
} catch (err) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed");
console.error("[createPortal] error:", err);
return errorResponse(request, err instanceof Error ? err.message : "Failed", 500);
}
Expand All @@ -121,6 +126,8 @@ export const getSubscription = httpAction(async (ctx, request) => {

return jsonResponse(request, { subscription: sub, plan: sub?.plan ?? "free" });
} catch (err) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed");
console.error("[getSubscription] error:", err);
return errorResponse(request, err instanceof Error ? err.message : "Failed", 500);
}
Expand Down
9 changes: 6 additions & 3 deletions convex/bitcoinAnchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ function buildCanonicalState(

/**
* Internal mutation to create an anchor record.
* Called by the action after computing the hash.
* Called by the action after computing the hash. Not public: a direct caller could
* record an arbitrary hash/snapshot instead of the server-computed state.
*/
export const { public: createAnchorRecord, internal: createAnchorRecordInternal } = actorMutation({
export const { internal: createAnchorRecordInternal } = actorMutation({
authority: "owner",
resources: args => ({ lists: [args.listId] }),
scope: "items:write",
Expand All @@ -107,8 +108,10 @@ export const { public: createAnchorRecord, internal: createAnchorRecordInternal

/**
* Update anchor status after Bitcoin inscription.
* Internal only: inscription status, txid and confirmations come from the inscription
* path, never from a list owner or agent key asserting them.
*/
export const { public: updateAnchorStatus, internal: updateAnchorStatusInternal } = actorMutation({
export const { internal: updateAnchorStatusInternal } = actorMutation({
authority: "owner",
resources: args => ({ anchors: [args.anchorId] }),
scope: "items:write",
Expand Down
11 changes: 10 additions & 1 deletion convex/categoriesHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { authenticatedRequest } from "./lib/actor";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { jsonResponse, errorResponse } from "./lib/httpResponses";
import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses";
import { authErrorData } from "./lib/authError";

/**
* POST /api/categories/create
Expand Down Expand Up @@ -43,6 +44,8 @@ export const createCategory = httpAction(async (ctx, request) => {

return jsonResponse(request, { categoryId });
} catch (error) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed");
console.error("[categoriesHttp] createCategory error:", error);
return errorResponse(
request,
Expand Down Expand Up @@ -83,6 +86,8 @@ export const renameCategory = httpAction(async (ctx, request) => {

return jsonResponse(request, { success: true });
} catch (error) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed");
console.error("[categoriesHttp] renameCategory error:", error);
return errorResponse(
request,
Expand Down Expand Up @@ -122,6 +127,8 @@ export const deleteCategory = httpAction(async (ctx, request) => {

return jsonResponse(request, { success: true });
} catch (error) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed");
console.error("[categoriesHttp] deleteCategory error:", error);
return errorResponse(
request,
Expand Down Expand Up @@ -162,6 +169,8 @@ export const setListCategory = httpAction(async (ctx, request) => {

return jsonResponse(request, { success: true });
} catch (error) {
// Authentication failures keep the shared 401/403 contract.
if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed");
console.error("[categoriesHttp] setListCategory error:", error);
return errorResponse(
request,
Expand Down
16 changes: 15 additions & 1 deletion convex/comments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { canUserEditList, canUserViewList } from "./lib/permissions";
import { actorQuery, actorMutation } from "./lib/authenticated";
import { resourceUnavailable } from "./lib/authError";
import { withoutCredential } from "./lib/actor";
/**
* Comments API - Threaded discussions on items for shared lists.
* Enables collaboration through item-level comments.
Expand Down Expand Up @@ -50,7 +51,7 @@ export const { public: getItemComments, internal: getItemCommentsInternal } = ac
.collect();

// Sort by createdAt ascending (oldest first for a thread)
return comments.sort((a, b) => a.createdAt - b.createdAt);
return comments.sort((a, b) => a.createdAt - b.createdAt).map(withoutCredential);
},
});

Expand Down Expand Up @@ -89,6 +90,7 @@ export const { public: addComment, internal: addCommentInternal } = actorMutatio
return await ctx.db.insert("comments", {
itemId: args.itemId,
userDid: ctx.actor.did,
credential: ctx.actor.credential,
Comment thread
pullfrog[bot] marked this conversation as resolved.
text: args.text.trim(),
createdAt: Date.now(),
});
Expand Down Expand Up @@ -129,6 +131,18 @@ export const { public: deleteComment, internal: deleteCommentInternal } = actorM
}

await ctx.db.delete(args.commentId);
// Deletion leaves an audit row naming who removed it with which credential. Only
// the comment ID is kept: not its text or author, which readers of a later
// published list must not learn.
await ctx.db.insert("activities", {
listId: item.listId,
itemId: item._id,
actorDid: ctx.actor.did,
credential: ctx.actor.credential,
type: "comment_deleted",
metadata: { note: JSON.stringify({ commentId: comment._id }) },
createdAt: Date.now(),
});
},
});

Expand Down
11 changes: 6 additions & 5 deletions convex/didLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { v } from "convex/values";
import { internalMutation, query } from "./_generated/server";
import { internalMutation, internalQuery } from "./_generated/server";

/**
* Store or update a user's DID log.
Expand Down Expand Up @@ -46,9 +46,10 @@ export const upsertDidLog = internalMutation({
});

/**
* Get a DID log by path (for resolution).
* Get a DID log by path (for resolution). Served publicly by the HTTP resolver;
* the lookups themselves are internal so only that projection is exposed.
*/
export const getDidLogByPath = query({
export const getDidLogByPath = internalQuery({
args: { path: v.string() },
handler: async (ctx, args) => {
const record = await ctx.db
Expand All @@ -62,7 +63,7 @@ export const getDidLogByPath = query({
/**
* Get the full DID log record by path (includes userDid).
*/
export const getDidLogRecordByPath = query({
export const getDidLogRecordByPath = internalQuery({
args: { path: v.string() },
handler: async (ctx, args) => {
return await ctx.db
Expand All @@ -75,7 +76,7 @@ export const getDidLogRecordByPath = query({
/**
* Get a DID log by user DID.
*/
export const getDidLogByUserDid = query({
export const getDidLogByUserDid = internalQuery({
args: { userDid: v.string() },
handler: async (ctx, args) => {
const record = await ctx.db
Expand Down
4 changes: 2 additions & 2 deletions convex/didLogsHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { httpAction } from "./_generated/server";
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
import { requireAuth, AuthError } from "./lib/auth";
import { assertDidLogOwnership, DidLogOwnershipError } from "./lib/didLogAuth";

Expand Down Expand Up @@ -89,7 +89,7 @@ export const getDidLog = httpAction(async (ctx, request) => {
});
}

const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path });
const log = await ctx.runQuery(internal.didLogs.getDidLogByPath, { path });

if (!log) {
return new Response("Not found", {
Expand Down
55 changes: 26 additions & 29 deletions convex/didResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import { actorMutation } from "./lib/authenticated";
* Queries for serving list resources publicly.
*
* These are used by the HTTP handlers to serve lists as Originals resources
* at /{userPath}/resources/list-{id}.
* at /{userPath}/resources/list-{id}. They are internal: the HTTP handlers
* project the public fields, while these return whole list documents.
*/

import { v } from "convex/values";
import { query } from "./_generated/server";
import { internalQuery } from "./_generated/server";
import type { Id } from "./_generated/dataModel";

/**
* Get a list by its Convex ID, verifying ownership by DID.
* Returns null if not found or owner doesn't match.
*/
export const getPublicList = query({
export const getPublicList = internalQuery({
args: {
listId: v.string(),
ownerDid: v.string(),
Expand Down Expand Up @@ -43,7 +44,7 @@ export const getPublicList = query({
* Get all items for a list (public view — no auth required).
* Only returns non-sensitive fields.
*/
export const getPublicListItems = query({
export const getPublicListItems = internalQuery({
args: {
listId: v.id("lists"),
},
Expand Down Expand Up @@ -81,36 +82,32 @@ export const getPublicListItems = query({
});

/**
* Get a list by ID without owner check (used as fallback for legacy users
* who don't have didLogs rows yet).
* Fallback for owners without a didLogs row at this path: a published list whose
* publication DID names this path. The controller derived from that DID must be the
* list owner's current or legacy DID, so one account's publication can never be
* served under another account's path. Returns null for every failure.
*/
export const getListById = query({
args: { listId: v.string() },
export const getPublishedListForPath = internalQuery({
args: { listId: v.string(), userPath: v.string() },
handler: async (ctx, args) => {
let list;
try {
const list = await ctx.db.get(args.listId as Id<"lists">);
if (!list) return null;
const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first();
return pub?.status === "active" ? list : null;
list = await ctx.db.get(args.listId as Id<"lists">);
} catch {
return null;
return null; // Invalid ID format
}
},
});

/**
* Get active publication for a list.
*/
export const getActivePublicationByListId = query({
args: { listId: v.id("lists") },
handler: async (ctx, args) => {
const pub = await ctx.db
.query("publications")
.withIndex("by_list", (q) => q.eq("listId", args.listId))
.first();

if (!pub || pub.status !== "active") return null;
return pub;
if (!list) return null;
const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first();
// Expected: did:webvh:{scid}:{domain}:{userPath}/resources/list-{listId}
const suffix = `/resources/list-${list._id}`;
if (pub?.status !== "active" || !pub.webvhDid.endsWith(`:${args.userPath}${suffix}`)) return null;
const controllerDid = pub.webvhDid.slice(0, -suffix.length);
if (list.ownerDid !== controllerDid) {
const owner = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", list.ownerDid)).first()
?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", list.ownerDid)).first();
if (!owner || ![owner.did, owner.legacyDid].includes(controllerDid)) return null;
}
return { list, controllerDid };
},
});

Expand Down
Loading
Loading