feat: add dataset consent and primary character model

This commit is contained in:
jb
2026-09-19 12:12:10 -04:00
parent a3dc8637f0
commit 4d3c7a7521
23 changed files with 1068 additions and 138 deletions
+17 -27
View File
@@ -1,6 +1,6 @@
import { queryGeneric } from "convex/server";
import { v } from "convex/values";
import { characterStateValidator } from "./schema";
import { characterStateValidator, authorizationStatusValidator, characterLoginRoleValidator, datasetStatusValidator } from "./schema";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
const safeAssetSyncValidator = v.object({
@@ -14,33 +14,28 @@ const safeCharacterValidator = v.object({
userId: v.string(),
name: v.string(),
scopes: v.array(v.string()),
loginRole: characterLoginRoleValidator,
authorizationStatus: authorizationStatusValidator,
grantRevision: v.number(),
credentialVersion: v.number(),
authorizationEpoch: v.number(),
datasets: v.array(datasetStatusValidator),
assets: safeAssetSyncValidator,
});
function isUsableSession(session: any, timestamp = Date.now()): boolean {
return Boolean(
session &&
!session.revokedAt &&
session.expiresAt > timestamp,
);
return Boolean(session && !session.revokedAt && session.expiresAt > timestamp);
}
function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean {
try {
return capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
} catch {
// A malformed backend setting must fail closed, not expose a reactive
// tenant projection while the web process reports a configuration error.
return false;
}
}
/**
* Browser-facing character status. Convex validates RS256 issuer/audience
* through auth.config.ts; the checks here make the application boundary and
* session revocation explicit as well. No owner hashes, tokens, generations,
* or raw synchronization errors cross this projection.
*/
/** Browser-facing status: no credentials, owner evidence, generations or raw errors. */
export const characters = queryGeneric({
args: {},
returns: v.union(v.null(), v.array(safeCharacterValidator)),
@@ -48,31 +43,26 @@ export const characters = queryGeneric({
if (!deploymentCapabilityEnabled('subscriptions')) return null;
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const expectedIssuer = process.env.APP_AUTH_ISSUER;
if (!expectedIssuer || identity.issuer !== expectedIssuer) return null;
// Convex's customJwt provider enforces applicationID/audience before this
// query runs. `aud` is a reserved claim and is not exposed on UserIdentity;
// only the application issuer and session binding are checked here.
const customIdentity = identity as typeof identity & { sid?: unknown };
const sessionId = typeof customIdentity.sid === "string" ? customIdentity.sid : null;
if (!sessionId) return null;
const session = await ctx.db
.query("sessions")
.withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId))
.first();
const session = await ctx.db.query("sessions").withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId)).first();
if (!isUsableSession(session) || session.userId !== identity.subject) return null;
const rows = await ctx.db
.query("characters")
.withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject))
.collect();
const rows = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject)).collect();
return rows.map((character: any) => ({
id: character.characterId,
userId: identity.subject,
name: character.name,
scopes: character.scopes,
loginRole: character.loginRole,
authorizationStatus: character.authorizationStatus,
grantRevision: character.grantRevision,
credentialVersion: character.credentialVersion,
authorizationEpoch: character.authorizationEpoch,
datasets: character.datasetStatuses,
assets: {
status: character.state,
...(character.syncedAt === undefined ? {} : { syncedAt: character.syncedAt }),