fix(core): token writes keep their actor in history but are user-owned in field_sources

fieldOwner(actor) maps every non-plugin actor to "user" wherever field_sources is written
(entries, engagements, list ownership from history), so syncs never overwrite a field an API
token edited. authenticateAppToken also returns the token name; revokeAppToken reports whether
it revoked; listSuggestions queries the canonical work id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 21:29:38 +01:00
1 parent e3a759d768
commit aa8e4db070
6 files changed
+110 -18

No files matched your search

+12 -4
View File
@@ -170,11 +170,17 @@ export async function createAppToken(
}
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> {
return (await authenticateAppToken(db, token, scope))?.user ?? null;
}
/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:<name>`). */
export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> {
const row = await db.k
.selectFrom("app_tokens")
.innerJoin("users", "users.id", "app_tokens.user_id")
.select([
"app_tokens.id as token_id",
"app_tokens.name as token_name",
"app_tokens.scopes",
"users.id",
"users.username",
@@ -186,17 +192,19 @@ export async function verifyAppToken(db: Db, token: string, scope: TokenScope):
.executeTakeFirst();
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null;
await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
return toUser(row);
return { user: toUser(row), tokenName: row.token_name };
}
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<void> {
await db.k
/** Revoke one of the user's live tokens. Returns false when no such live token exists (unknown, foreign or revoked). */
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<boolean> {
const r = await db.k
.updateTable("app_tokens")
.set({ revoked_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("revoked_at", "is", null)
.execute();
.executeTakeFirst();
return Number(r.numUpdatedRows) === 1;
}
export interface AppTokenInfo {
+3 -3
View File
@@ -15,7 +15,7 @@ import {
toStoredDate,
} from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
export interface EngagementPatch {
@@ -144,7 +144,7 @@ async function write(
): Promise<Engagement> {
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
await k
.updateTable("engagements")
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
@@ -175,7 +175,7 @@ export async function addEngagement(
const { mediaType } = await requireEntry(k, userId, entryId);
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources: FieldSources = {};
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
const now = nowIso();
const row: Row = {
id: newId(),
+8 -5
View File
@@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
// ---------------------------------------------------------------------------------------------
// Shared user-layer helpers (also used by engagements, lists and copies).
@@ -320,7 +320,7 @@ async function writeEntry(
): Promise<Entry> {
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
for (const key of Object.keys(patch)) {
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor);
if (fieldSources[key] === undefined) delete fieldSources[key];
}
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
@@ -985,14 +985,17 @@ export interface MatchSuggestion {
createdAt: string;
}
/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */
/**
* The user's pending match suggestions for a work (after following aliases and merges to the canonical work), best first.
* Throws `NotFoundError` if the work is not visible.
*/
export async function listSuggestions(db: Db, userId: string, workId: string): Promise<MatchSuggestion[]> {
await resolveVisibleWork(db.k, userId, workId);
const work = await resolveVisibleWork(db.k, userId, workId);
const rows = await db.k
.selectFrom("match_suggestions")
.selectAll()
.where("user_id", "=", userId)
.where("work_id", "=", workId)
.where("work_id", "=", work.id)
.where("status", "=", "pending")
.orderBy("score", "desc")
.orderBy("id")
+9
View File
@@ -12,6 +12,15 @@ import { restoreListFromHistory } from "./lists.ts";
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`;
/**
* Who owns a field in `field_sources` after `actor` writes it. Only plugin actors keep their name; the user,
* through the UI or an API token, is always `"user"`, so syncs never overwrite a field a script edited.
* History rows keep the full actor.
*/
export function fieldOwner(actor: string): string {
return actor.startsWith("plugin:") ? actor : "user";
}
/** User-layer entities that write `user_history`. */
export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
+3 -3
View File
@@ -6,7 +6,7 @@ import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
import type { Actor, Executor } from "../history.ts";
import { type Actor, type Executor, fieldOwner } from "../history.ts";
import { createList, replaceListItems, updateList } from "../lists.ts";
import { matchWork } from "../matching.ts";
import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts";
@@ -146,8 +146,8 @@ export async function listFieldSources(k: Executor, listId: string, sourceActor:
const fs: Record<string, string> = { items: sourceActor };
for (const r of rows) {
const after = fromJson<Fields>(r.after, {});
for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
if (r.actor === "user") fs.items = "user";
for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor);
if (fieldOwner(r.actor) === "user") fs.items = "user";
}
return fs;
}