feat: add dataset consent and primary character model
This commit is contained in:
+366
-43
@@ -6,6 +6,12 @@ import {
|
||||
import { v } from "convex/values";
|
||||
import { assetValidator } from "./schema";
|
||||
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
|
||||
import {
|
||||
CAPABILITY_DEFINITIONS,
|
||||
SERVER_SCOPE_ALLOWLIST,
|
||||
validateCapabilityScopes,
|
||||
} from "../lib/domain/capabilities";
|
||||
import type { Capability } from "../lib/domain/model";
|
||||
|
||||
const MAX_ASSET_CHUNK = 250;
|
||||
const SYNC_LEASE_MS = 5 * 60 * 1000;
|
||||
@@ -14,6 +20,17 @@ const MAX_CHUNKS = 4_096;
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
const OAUTH_MAX_LIFETIME_MS = 10 * 60 * 1000;
|
||||
const SNAPSHOT_MAX_LIFETIME_MS = 60 * 60 * 1000;
|
||||
const DATASET_SCOPE_REQUIREMENTS: Record<string, readonly string[]> = {
|
||||
assets: ['esi-assets.read_assets.v1'],
|
||||
wallet_balance: ['esi-wallet.read_character_wallet.v1'],
|
||||
wallet_journal: ['esi-wallet.read_character_wallet.v1'],
|
||||
wallet_transactions: ['esi-wallet.read_character_wallet.v1'],
|
||||
blueprints: ['esi-characters.read_blueprints.v1'],
|
||||
skills: ['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1'],
|
||||
};
|
||||
const DATASET_NAMES = Object.keys(DATASET_SCOPE_REQUIREMENTS);
|
||||
const CONSENT_INTENTS = ['initial_login', 'link_character', 'upgrade_dataset', 'reconnect'] as const;
|
||||
const CONSENT_CAPABILITIES = Object.keys(CAPABILITY_DEFINITIONS) as Capability[];
|
||||
|
||||
const bridgeArgs = { bridgeSecret: v.string() };
|
||||
const characterArgs = { userId: v.string(), characterId: v.number() };
|
||||
@@ -84,6 +101,106 @@ async function findSessionByHash(ctx: any, sessionHash: string): Promise<any | n
|
||||
.first();
|
||||
}
|
||||
|
||||
function scopesEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && [...left].sort().every((scope, index) => scope === [...right].sort()[index]);
|
||||
}
|
||||
|
||||
function validActualScopes(scopes: readonly string[]): boolean {
|
||||
return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope));
|
||||
}
|
||||
|
||||
function datasetStatusesFor(character: any): any[] {
|
||||
return character.datasetStatuses;
|
||||
}
|
||||
|
||||
function updateDatasetStatuses(
|
||||
character: any,
|
||||
scopes: readonly string[],
|
||||
grantRevision: number,
|
||||
authorizationEpoch: number,
|
||||
credentialVersion: number,
|
||||
timestamp: number,
|
||||
): { statuses: any[]; assetsInvalidated: boolean } {
|
||||
const previous = datasetStatusesFor(character);
|
||||
const statuses: any[] = [];
|
||||
let assetsInvalidated = false;
|
||||
for (const dataset of DATASET_NAMES) {
|
||||
const old = previous.find((entry: any) => entry.dataset === dataset);
|
||||
const required = DATASET_SCOPE_REQUIREMENTS[dataset]!;
|
||||
const authorized = required.every((scope) => scopes.includes(scope));
|
||||
if (!authorized) {
|
||||
statuses.push({
|
||||
dataset,
|
||||
status: 'blocked',
|
||||
grantRevision,
|
||||
authorizationEpoch,
|
||||
credentialVersion,
|
||||
invalidatedAt: timestamp,
|
||||
...(dataset === 'assets' ? {} : {}),
|
||||
});
|
||||
if (dataset === 'assets' && (old?.status === 'ready' || old?.generation !== undefined || character.activeGeneration !== undefined)) assetsInvalidated = true;
|
||||
continue;
|
||||
}
|
||||
// A newly authorized dataset has no coverage. Existing coverage remains
|
||||
// usable only when its authority epoch/revision still matches.
|
||||
const wasCurrent = old && old.grantRevision === grantRevision && old.authorizationEpoch === authorizationEpoch;
|
||||
const status = wasCurrent ? old.status : old?.status === 'ready' ? 'ready' : 'never';
|
||||
statuses.push({
|
||||
dataset,
|
||||
status,
|
||||
grantRevision,
|
||||
authorizationEpoch,
|
||||
credentialVersion,
|
||||
...(status === 'ready' && old?.generation === undefined && dataset === 'assets' && character.activeGeneration !== undefined
|
||||
? { generation: character.activeGeneration } : {}),
|
||||
...(old?.generation !== undefined && status === 'ready' ? { generation: old.generation } : {}),
|
||||
...(old?.syncedAt === undefined ? {} : { syncedAt: old.syncedAt }),
|
||||
...(old?.error === undefined ? {} : { error: old.error }),
|
||||
});
|
||||
}
|
||||
return { statuses, assetsInvalidated };
|
||||
}
|
||||
|
||||
function quarantinedDatasetStatuses(character: any, credentialVersion: number): any[] {
|
||||
return DATASET_NAMES.map((dataset) => ({
|
||||
dataset,
|
||||
status: 'quarantined',
|
||||
grantRevision: character.grantRevision,
|
||||
authorizationEpoch: character.authorizationEpoch + 1,
|
||||
credentialVersion,
|
||||
invalidatedAt: now(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function quarantineCharacter(ctx: any, character: any, reason = 'Authorization quarantined'): Promise<void> {
|
||||
const timestamp = now();
|
||||
const epoch = character.authorizationEpoch + 1;
|
||||
await ctx.db.patch(character._id, {
|
||||
authorizationStatus: 'quarantined',
|
||||
authorizationEpoch: epoch,
|
||||
datasetStatuses: quarantinedDatasetStatuses(character, character.credentialVersion),
|
||||
state: 'error',
|
||||
activeGeneration: undefined,
|
||||
syncGeneration: undefined,
|
||||
syncLeaseExpiresAt: undefined,
|
||||
error: reason.slice(0, MAX_ERROR_LENGTH),
|
||||
});
|
||||
const generations = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) =>
|
||||
q.eq('userId', character.userId).eq('characterId', character.characterId)).collect();
|
||||
for (const generation of generations) {
|
||||
if (generation.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization quarantined' });
|
||||
}
|
||||
const jobs = await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', character.userId)).collect();
|
||||
for (const job of jobs) {
|
||||
if (job.characterId === character.characterId && !['succeeded', 'failed', 'cancelled'].includes(job.status)) {
|
||||
await ctx.db.patch(job._id, { status: 'quarantined', updatedAt: timestamp, leaseExpiresAt: undefined });
|
||||
}
|
||||
}
|
||||
const token = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) =>
|
||||
q.eq('userId', character.userId).eq('characterId', character.characterId)).first();
|
||||
if (token) await ctx.db.delete(token._id);
|
||||
}
|
||||
|
||||
function isUsableSession(session: any, timestamp = now()): boolean {
|
||||
return Boolean(
|
||||
session &&
|
||||
@@ -125,6 +242,12 @@ export const bridgeCreateOAuth = mutationGeneric({
|
||||
encryptedVerifier: v.string(),
|
||||
expiresAt: v.number(),
|
||||
returnTo: v.string(),
|
||||
intent: v.string(),
|
||||
capability: v.string(),
|
||||
requestedScopes: v.array(v.string()),
|
||||
approvedExistingScopes: v.array(v.string()),
|
||||
initiatingSessionHash: v.optional(v.string()),
|
||||
targetCharacterId: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
@@ -136,6 +259,12 @@ export const bridgeCreateOAuth = mutationGeneric({
|
||||
) {
|
||||
throw new Error("Invalid OAuth state expiry");
|
||||
}
|
||||
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
|
||||
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
|
||||
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
|
||||
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
|
||||
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
|
||||
if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes");
|
||||
// Keep this bounded cleanup opportunistic; retention/GC is intentionally
|
||||
// outside this bridge transaction.
|
||||
const expired = await ctx.db
|
||||
@@ -156,6 +285,12 @@ export const bridgeCreateOAuth = mutationGeneric({
|
||||
encryptedVerifier: args.encryptedVerifier,
|
||||
expiresAt: args.expiresAt,
|
||||
returnTo: args.returnTo,
|
||||
intent: args.intent,
|
||||
capability: args.capability,
|
||||
requestedScopes: args.requestedScopes,
|
||||
approvedExistingScopes: args.approvedExistingScopes,
|
||||
...(args.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: args.initiatingSessionHash }),
|
||||
...(args.targetCharacterId === undefined ? {} : { targetCharacterId: args.targetCharacterId }),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
@@ -183,6 +318,12 @@ export const bridgeConsumeOAuth = mutationGeneric({
|
||||
encryptedVerifier: state.encryptedVerifier,
|
||||
expiresAt: state.expiresAt,
|
||||
returnTo: state.returnTo,
|
||||
intent: state.intent,
|
||||
capability: state.capability,
|
||||
requestedScopes: state.requestedScopes,
|
||||
approvedExistingScopes: state.approvedExistingScopes,
|
||||
...(state.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: state.initiatingSessionHash }),
|
||||
...(state.targetCharacterId === undefined ? {} : { targetCharacterId: state.targetCharacterId }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -198,6 +339,12 @@ const completeLoginArgs = {
|
||||
tokenExpiresAt: v.number(),
|
||||
sessionHash: v.string(),
|
||||
sessionExpiresAt: v.number(),
|
||||
intent: v.string(),
|
||||
capability: v.string(),
|
||||
requestedScopes: v.array(v.string()),
|
||||
approvedExistingScopes: v.array(v.string()),
|
||||
initiatingSessionHash: v.optional(v.string()),
|
||||
targetCharacterId: v.optional(v.number()),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -209,83 +356,131 @@ export const bridgeCompleteLogin = mutationGeneric({
|
||||
args: completeLoginArgs,
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const timestamp = now();
|
||||
if (!validActualScopes(args.scopes)) throw new Error("Invalid granted scopes");
|
||||
if (args.scopes.some((scope) => !args.requestedScopes.includes(scope))) throw new Error("Granted scopes exceed the bound request");
|
||||
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
|
||||
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
|
||||
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
|
||||
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
|
||||
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
|
||||
if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes");
|
||||
if (args.targetCharacterId !== undefined && args.targetCharacterId !== args.characterId) {
|
||||
throw new Error("OAuth character mismatch");
|
||||
}
|
||||
|
||||
const byCharacterId = await ctx.db
|
||||
.query("characters")
|
||||
.withIndex("by_character_id", (q: any) => q.eq("characterId", args.characterId))
|
||||
.collect();
|
||||
if (byCharacterId.some((character: any) => character.ownerHash !== args.ownerHash)) {
|
||||
throw new Error("Character ownership conflict");
|
||||
const conflicting = byCharacterId.filter((character: any) => character.ownerHash !== args.ownerHash);
|
||||
if (conflicting.length > 0) {
|
||||
// Never attach a provider character to a new owner. Quarantine every
|
||||
// old row in this transaction; recovery/transfer is not part of this
|
||||
// local prototype.
|
||||
for (const oldCharacter of conflicting) {
|
||||
await quarantineCharacter(ctx, oldCharacter, "Character ownership changed; reconnect required");
|
||||
}
|
||||
// Return a committed rejection so quarantine is not rolled back by a
|
||||
// thrown Convex error. The server adapter converts this to a fixed
|
||||
// callback failure without issuing a session.
|
||||
return { userId: conflicting[0]!.userId, sessionId: "", rejected: "ownership_conflict" };
|
||||
}
|
||||
const existingCharacter = byCharacterId[0] ?? null;
|
||||
if (args.intent === "link_character" && existingCharacter) {
|
||||
throw new Error("Character is already linked; use reconnect or upgrade");
|
||||
}
|
||||
|
||||
let resolvedUserId: string;
|
||||
if (existingCharacter) {
|
||||
if (existingCharacter.ownerHash !== args.ownerHash) {
|
||||
throw new Error("Character ownership conflict");
|
||||
}
|
||||
if (args.userId !== undefined && args.userId !== existingCharacter.userId) {
|
||||
throw new Error("Character belongs to another user");
|
||||
}
|
||||
resolvedUserId = existingCharacter.userId;
|
||||
if (args.userId === undefined && existingCharacter.loginRole === "data_only") {
|
||||
throw new Error("Data-only character cannot authenticate while signed out");
|
||||
}
|
||||
} else {
|
||||
// A signed-out first login creates one app user for this character. A
|
||||
// new character may only be attached when the current app user is
|
||||
// explicitly supplied by the authenticated linking flow.
|
||||
resolvedUserId = args.userId ?? newIdentifier("user");
|
||||
}
|
||||
|
||||
const sameSession = await findSessionByHash(ctx, args.sessionHash);
|
||||
if (sameSession) {
|
||||
throw new Error("Session hash collision");
|
||||
if (sameSession) throw new Error("Session hash collision");
|
||||
|
||||
// Linking, upgrading and reconnecting are authorized by the still-live
|
||||
// application session that initiated consent.
|
||||
const preexistingUser = await ctx.db.query("users").withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId)).first();
|
||||
if (args.userId !== undefined && !preexistingUser) throw new Error("User does not exist");
|
||||
let initiatingSession: any | null = null;
|
||||
if (args.userId !== undefined) {
|
||||
initiatingSession = args.initiatingSessionHash
|
||||
? await findSessionByHash(ctx, args.initiatingSessionHash)
|
||||
: null;
|
||||
if (!isUsableSession(initiatingSession, timestamp) || initiatingSession.userId !== args.userId) {
|
||||
throw new Error("Initiating session is no longer valid");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.intent === "upgrade_dataset" || args.intent === "reconnect") {
|
||||
if (args.userId === undefined || args.targetCharacterId !== args.characterId) throw new Error("OAuth target is required");
|
||||
}
|
||||
if (args.intent === "link_character" && args.userId === undefined) throw new Error("Authenticated session is required to link a character");
|
||||
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId))
|
||||
.first();
|
||||
if (!user) {
|
||||
if (args.userId !== undefined || existingCharacter) {
|
||||
throw new Error("User does not exist");
|
||||
}
|
||||
await ctx.db.insert("users", { userId: resolvedUserId, createdAt: now() });
|
||||
if (args.userId !== undefined || existingCharacter) throw new Error("User does not exist");
|
||||
await ctx.db.insert("users", { userId: resolvedUserId, createdAt: timestamp });
|
||||
}
|
||||
|
||||
let character = existingCharacter;
|
||||
if (!character) {
|
||||
character = await findCharacter(ctx, resolvedUserId, args.characterId);
|
||||
}
|
||||
if (!character) character = await findCharacter(ctx, resolvedUserId, args.characterId);
|
||||
const oldScopes = character?.scopes ?? [];
|
||||
const oldRevision = character?.grantRevision ?? 1;
|
||||
const grantChanged = character ? !scopesEqual(oldScopes, args.scopes) : false;
|
||||
const grantRevision = character ? oldRevision + (grantChanged ? 1 : 0) : 1;
|
||||
const authorizationEpoch = character?.authorizationEpoch ?? 1;
|
||||
const existingTokenForVersion = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId)).first();
|
||||
const credentialVersion = Math.max(character?.credentialVersion ?? 0, existingTokenForVersion?.version ?? 0) + 1;
|
||||
const updated = character
|
||||
? updateDatasetStatuses(character, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp)
|
||||
: updateDatasetStatuses({ state: "never", scopes: [], datasetStatuses: [] }, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp);
|
||||
|
||||
const basePatch = {
|
||||
name: args.name,
|
||||
scopes: args.scopes,
|
||||
ownerHash: args.ownerHash,
|
||||
loginRole: character ? character.loginRole : (args.userId === undefined ? "primary" : "data_only"),
|
||||
authorizationStatus: "active" as const,
|
||||
grantRevision,
|
||||
credentialVersion,
|
||||
authorizationEpoch,
|
||||
datasetStatuses: updated.statuses,
|
||||
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
|
||||
};
|
||||
if (character) {
|
||||
if (character.ownerHash !== args.ownerHash || character.userId !== resolvedUserId) {
|
||||
throw new Error("Character ownership conflict");
|
||||
}
|
||||
await ctx.db.patch(character._id, {
|
||||
name: args.name,
|
||||
scopes: args.scopes,
|
||||
ownerHash: args.ownerHash,
|
||||
});
|
||||
await ctx.db.patch(character._id, basePatch);
|
||||
} else {
|
||||
await ctx.db.insert("characters", {
|
||||
userId: resolvedUserId,
|
||||
characterId: args.characterId,
|
||||
name: args.name,
|
||||
ownerHash: args.ownerHash,
|
||||
scopes: args.scopes,
|
||||
state: "never",
|
||||
...basePatch,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenRecord = await ctx.db
|
||||
.query("tokenRecords")
|
||||
.withIndex("by_user_character", (q: any) =>
|
||||
q.eq("userId", resolvedUserId).eq("characterId", args.characterId),
|
||||
)
|
||||
.withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId))
|
||||
.first();
|
||||
if (tokenRecord) {
|
||||
await ctx.db.patch(tokenRecord._id, {
|
||||
encryptedTokens: args.encryptedTokens,
|
||||
tokenExpiresAt: args.tokenExpiresAt,
|
||||
scopes: args.scopes,
|
||||
version: tokenRecord.version + 1,
|
||||
version: credentialVersion,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("tokenRecords", {
|
||||
@@ -294,7 +489,7 @@ export const bridgeCompleteLogin = mutationGeneric({
|
||||
encryptedTokens: args.encryptedTokens,
|
||||
tokenExpiresAt: args.tokenExpiresAt,
|
||||
scopes: args.scopes,
|
||||
version: 1,
|
||||
version: credentialVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,6 +500,9 @@ export const bridgeCompleteLogin = mutationGeneric({
|
||||
userId: resolvedUserId,
|
||||
expiresAt: args.sessionExpiresAt,
|
||||
});
|
||||
// Replacement is atomic with persistence; the callback never has to
|
||||
// replay a code if cookie delivery later fails.
|
||||
if (initiatingSession && !initiatingSession.revokedAt) await ctx.db.patch(initiatingSession._id, { revokedAt: timestamp });
|
||||
return { userId: resolvedUserId, sessionId };
|
||||
},
|
||||
});
|
||||
@@ -314,7 +512,7 @@ export const bridgeTokenRecord = queryGeneric({
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
if (!character) return null;
|
||||
if (!character || character.authorizationStatus !== "active") return null;
|
||||
return await ctx.db
|
||||
.query("tokenRecords")
|
||||
.withIndex("by_user_character", (q: any) =>
|
||||
@@ -345,8 +543,9 @@ export const bridgeSaveTokens = mutationGeneric({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
if (!validActualScopes(args.scopes)) return false;
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
if (!character) return false;
|
||||
if (!character || character.authorizationStatus !== "active") return false;
|
||||
const record = await ctx.db
|
||||
.query("tokenRecords")
|
||||
.withIndex("by_user_character", (q: any) =>
|
||||
@@ -354,13 +553,22 @@ export const bridgeSaveTokens = mutationGeneric({
|
||||
)
|
||||
.first();
|
||||
if (!record || record.version !== args.expectedVersion) return false;
|
||||
const nextVersion = record.version + 1;
|
||||
const nextRevision = character.grantRevision + (scopesEqual(character.scopes, args.scopes) ? 0 : 1);
|
||||
const updated = updateDatasetStatuses(character, args.scopes, nextRevision, character.authorizationEpoch, nextVersion, now());
|
||||
await ctx.db.patch(record._id, {
|
||||
encryptedTokens: args.encryptedTokens,
|
||||
tokenExpiresAt: args.tokenExpiresAt,
|
||||
scopes: args.scopes,
|
||||
version: record.version + 1,
|
||||
version: nextVersion,
|
||||
});
|
||||
await ctx.db.patch(character._id, {
|
||||
scopes: args.scopes,
|
||||
grantRevision: nextRevision,
|
||||
credentialVersion: nextVersion,
|
||||
datasetStatuses: updated.statuses,
|
||||
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
|
||||
});
|
||||
await ctx.db.patch(character._id, { scopes: args.scopes });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -376,6 +584,84 @@ export const bridgeCharacters = queryGeneric({
|
||||
},
|
||||
});
|
||||
|
||||
/** Change the one signed-out login character without changing authorization. */
|
||||
export const bridgeSetPrimary = mutationGeneric({
|
||||
args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
||||
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid");
|
||||
const target = await findCharacter(ctx, args.userId, args.characterId);
|
||||
const targetToken = target
|
||||
? await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first()
|
||||
: null;
|
||||
if (!target || !targetToken || target.authorizationStatus !== "active") {
|
||||
throw new Error("Character is not eligible to be primary");
|
||||
}
|
||||
const characters = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", args.userId)).collect();
|
||||
for (const character of characters) {
|
||||
if (character._id === target._id) {
|
||||
await ctx.db.patch(character._id, { loginRole: "primary" });
|
||||
} else if (character.loginRole === "primary" || character.loginRole === undefined) {
|
||||
await ctx.db.patch(character._id, { loginRole: "data_only" });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/** Local cancellation immediately fences private data and future work. */
|
||||
export const bridgeQuarantineAuthorization = mutationGeneric({
|
||||
args: { ...bridgeArgs, userId: v.string(), characterId: v.number() },
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
if (character) await quarantineCharacter(ctx, character, "Character ownership changed; reconnect required");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const bridgeInvalidateAuthorization = mutationGeneric({
|
||||
args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
||||
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid");
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
if (!character) throw new Error("Character not found");
|
||||
if (character.loginRole === "primary") throw new Error("Switch the primary character before cancelling its authorization");
|
||||
const timestamp = now();
|
||||
const epoch = character.authorizationEpoch + 1;
|
||||
const statuses = DATASET_NAMES.map((dataset) => ({
|
||||
dataset,
|
||||
status: "blocked" as const,
|
||||
grantRevision: character.grantRevision,
|
||||
authorizationEpoch: epoch,
|
||||
credentialVersion: character.credentialVersion,
|
||||
invalidatedAt: timestamp,
|
||||
}));
|
||||
await ctx.db.patch(character._id, {
|
||||
authorizationStatus: "revoked",
|
||||
authorizationEpoch: epoch,
|
||||
datasetStatuses: statuses,
|
||||
state: "error",
|
||||
activeGeneration: undefined,
|
||||
syncGeneration: undefined,
|
||||
syncLeaseExpiresAt: undefined,
|
||||
error: "Authorization cancelled",
|
||||
});
|
||||
const token = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first();
|
||||
if (token) await ctx.db.delete(token._id);
|
||||
const jobs = await ctx.db.query("jobs").withIndex("by_user_status", (q: any) => q.eq("userId", args.userId)).collect();
|
||||
for (const job of jobs) {
|
||||
if (job.characterId === args.characterId && !["succeeded", "failed", "cancelled"].includes(job.status)) {
|
||||
await ctx.db.patch(job._id, { status: "quarantined", updatedAt: timestamp, leaseExpiresAt: undefined });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Read an active snapshot one page of chunks at a time. The application adapter
|
||||
* walks this query's cursor, rather than issuing an unbounded Convex collect.
|
||||
@@ -391,7 +677,14 @@ export const bridgeAssetsPage = queryGeneric({
|
||||
handler: async (ctx, args) => {
|
||||
assertBridge(args.bridgeSecret);
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
const generation = character?.activeGeneration ?? null;
|
||||
const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === "assets");
|
||||
const authorized = character && character.authorizationStatus === "active"
|
||||
&& character.scopes.includes("esi-assets.read_assets.v1")
|
||||
&& assetStatus
|
||||
&& (assetStatus.status === "ready" || assetStatus.status === "error")
|
||||
&& assetStatus.grantRevision === character.grantRevision
|
||||
&& assetStatus.authorizationEpoch === character.authorizationEpoch;
|
||||
const generation = authorized ? character.activeGeneration ?? null : null;
|
||||
if (!character || !generation) {
|
||||
return { page: [], isDone: true, continueCursor: "", generation: null };
|
||||
}
|
||||
@@ -426,6 +719,9 @@ export const bridgeBeginSync = mutationGeneric({
|
||||
assertDeploymentCapability('asset_sync');
|
||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||
if (!character) throw new Error("Character not found");
|
||||
if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) {
|
||||
throw new Error("Asset authorization is not active");
|
||||
}
|
||||
const timestamp = now();
|
||||
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) {
|
||||
throw new Error("Character synchronization is already leased");
|
||||
@@ -437,9 +733,16 @@ export const bridgeBeginSync = mutationGeneric({
|
||||
generation,
|
||||
status: "staging",
|
||||
stagedChunkCount: 0,
|
||||
grantRevision: character.grantRevision,
|
||||
authorizationEpoch: character.authorizationEpoch,
|
||||
credentialVersion: character.credentialVersion,
|
||||
});
|
||||
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
||||
? { ...entry, status: "syncing", grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch }
|
||||
: entry);
|
||||
await ctx.db.patch(character._id, {
|
||||
state: "syncing",
|
||||
datasetStatuses: statuses,
|
||||
syncGeneration: generation,
|
||||
syncLeaseExpiresAt: timestamp + SYNC_LEASE_MS,
|
||||
error: undefined,
|
||||
@@ -565,14 +868,24 @@ export const bridgeFinishSync = mutationGeneric({
|
||||
throw new Error("Snapshot is missing chunks");
|
||||
}
|
||||
|
||||
if (generation.grantRevision !== character.grantRevision
|
||||
|| generation.authorizationEpoch !== character.authorizationEpoch
|
||||
|| generation.credentialVersion !== character.credentialVersion
|
||||
|| character.authorizationStatus !== "active") {
|
||||
throw new Error("Synchronization authorization is stale");
|
||||
}
|
||||
await ctx.db.patch(generation._id, {
|
||||
status: "complete",
|
||||
chunkCount: args.chunkCount,
|
||||
syncedAt: args.syncedAt,
|
||||
expiresAt: args.expiresAt,
|
||||
});
|
||||
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
||||
? { ...entry, status: "ready", generation: args.generation, syncedAt: args.syncedAt, error: undefined }
|
||||
: entry);
|
||||
await ctx.db.patch(character._id, {
|
||||
state: "ready",
|
||||
datasetStatuses: statuses,
|
||||
syncedAt: args.syncedAt,
|
||||
expiresAt: args.expiresAt,
|
||||
activeGeneration: args.generation,
|
||||
@@ -602,16 +915,26 @@ export const bridgeFailSync = mutationGeneric({
|
||||
.eq("generation", args.generation),
|
||||
)
|
||||
.first();
|
||||
if (generation) {
|
||||
await ctx.db.patch(generation._id, {
|
||||
status: "failed",
|
||||
error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH),
|
||||
});
|
||||
if (!generation || generation.status !== "staging"
|
||||
|| (character.syncLeaseExpiresAt ?? 0) <= now()
|
||||
|| generation.grantRevision !== character.grantRevision
|
||||
|| generation.authorizationEpoch !== character.authorizationEpoch
|
||||
|| generation.credentialVersion !== character.credentialVersion
|
||||
|| character.authorizationStatus !== "active") {
|
||||
throw new Error("Synchronization failure publication is stale");
|
||||
}
|
||||
await ctx.db.patch(generation._id, {
|
||||
status: "failed",
|
||||
error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH),
|
||||
});
|
||||
// Deliberately do not touch activeGeneration: the previous complete
|
||||
// snapshot remains available after a failed synchronization.
|
||||
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
||||
? { ...entry, status: character.activeGeneration ? "error" : "blocked", error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH) }
|
||||
: entry);
|
||||
await ctx.db.patch(character._id, {
|
||||
state: "error",
|
||||
datasetStatuses: statuses,
|
||||
error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH),
|
||||
syncGeneration: undefined,
|
||||
syncLeaseExpiresAt: undefined,
|
||||
|
||||
@@ -9,6 +9,37 @@ export const characterStateValidator = v.union(
|
||||
v.literal("error"),
|
||||
);
|
||||
|
||||
export const characterLoginRoleValidator = v.union(v.literal("primary"), v.literal("data_only"));
|
||||
export const authorizationStatusValidator = v.union(
|
||||
v.literal("active"),
|
||||
v.literal("revocation_pending"),
|
||||
v.literal("quarantined"),
|
||||
v.literal("revoked"),
|
||||
v.literal("transferred"),
|
||||
);
|
||||
export const datasetStateValidator = v.union(
|
||||
v.literal("never"),
|
||||
v.literal("queued"),
|
||||
v.literal("syncing"),
|
||||
v.literal("ready"),
|
||||
v.literal("partial"),
|
||||
v.literal("stale"),
|
||||
v.literal("blocked"),
|
||||
v.literal("error"),
|
||||
v.literal("quarantined"),
|
||||
);
|
||||
export const datasetStatusValidator = v.object({
|
||||
dataset: v.string(),
|
||||
status: datasetStateValidator,
|
||||
grantRevision: v.number(),
|
||||
authorizationEpoch: v.number(),
|
||||
credentialVersion: v.number(),
|
||||
generation: v.optional(v.string()),
|
||||
syncedAt: v.optional(v.number()),
|
||||
invalidatedAt: v.optional(v.number()),
|
||||
error: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const assetValidator = v.object({
|
||||
itemId: v.string(),
|
||||
characterId: v.number(),
|
||||
@@ -28,6 +59,12 @@ const characterFields = {
|
||||
ownerHash: v.string(),
|
||||
scopes: v.array(v.string()),
|
||||
state: characterStateValidator,
|
||||
loginRole: characterLoginRoleValidator,
|
||||
authorizationStatus: authorizationStatusValidator,
|
||||
grantRevision: v.number(),
|
||||
credentialVersion: v.number(),
|
||||
authorizationEpoch: v.number(),
|
||||
datasetStatuses: v.array(datasetStatusValidator),
|
||||
syncedAt: v.optional(v.number()),
|
||||
expiresAt: v.optional(v.number()),
|
||||
error: v.optional(v.string()),
|
||||
@@ -65,6 +102,13 @@ export default defineSchema({
|
||||
encryptedVerifier: v.string(),
|
||||
expiresAt: v.number(),
|
||||
returnTo: v.string(),
|
||||
// OAuth intent is bound server-side, not reconstructed from callback input.
|
||||
intent: v.string(),
|
||||
capability: v.string(),
|
||||
requestedScopes: v.array(v.string()),
|
||||
approvedExistingScopes: v.array(v.string()),
|
||||
initiatingSessionHash: v.optional(v.string()),
|
||||
targetCharacterId: v.optional(v.number()),
|
||||
consumedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_state_binding", ["stateHash", "bindingHash"])
|
||||
@@ -92,6 +136,9 @@ export default defineSchema({
|
||||
syncedAt: v.optional(v.number()),
|
||||
expiresAt: v.optional(v.number()),
|
||||
error: v.optional(v.string()),
|
||||
grantRevision: v.number(),
|
||||
authorizationEpoch: v.number(),
|
||||
credentialVersion: v.number(),
|
||||
})
|
||||
.index("by_user_character_generation", ["userId", "characterId", "generation"])
|
||||
.index("by_generation", ["generation"]),
|
||||
@@ -130,6 +177,7 @@ export default defineSchema({
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
characterId: v.optional(v.number()),
|
||||
})
|
||||
.index("by_user_status", ["userId", "status"])
|
||||
.index("by_lease_expires_at", ["leaseExpiresAt"]),
|
||||
|
||||
+17
-27
@@ -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 }),
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
/** Domain values are JSON-safe. EVE item/location IDs are decimal strings, not JS floats. */
|
||||
export type UserId = string;
|
||||
export type CharacterDatasetStatus = {
|
||||
dataset: string;
|
||||
status: 'never' | 'queued' | 'syncing' | 'ready' | 'partial' | 'stale' | 'blocked' | 'error' | 'quarantined';
|
||||
grantRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number;
|
||||
generation?: string;
|
||||
syncedAt?: number;
|
||||
invalidatedAt?: number;
|
||||
error?: string;
|
||||
};
|
||||
export type Character = {
|
||||
id: number;
|
||||
userId: UserId;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
/** Exactly one active primary may authenticate while signed out. */
|
||||
loginRole: 'primary' | 'data_only';
|
||||
authorizationStatus: 'active' | 'revocation_pending' | 'quarantined' | 'revoked' | 'transferred';
|
||||
grantRevision: number;
|
||||
credentialVersion: number;
|
||||
authorizationEpoch: number;
|
||||
datasets: CharacterDatasetStatus[];
|
||||
assets: { status: 'never' | 'syncing' | 'ready' | 'error'; syncedAt?: number; expiresAt?: number; error?: string };
|
||||
};
|
||||
export type Asset = {
|
||||
|
||||
@@ -32,7 +32,16 @@ export async function session(event: RequestEvent): Promise<Session | null> {
|
||||
function demoRepository(): CharacterAssetRepository {
|
||||
const now = Date.now();
|
||||
const characters: Character[] = [90000001, 90000002].map((id, index) => ({
|
||||
id, userId: 'demo', name: `Fixture Pilot ${index + 1}`, scopes: ['esi-assets.read_assets.v1'],
|
||||
id,
|
||||
userId: 'demo',
|
||||
name: `Fixture Pilot ${index + 1}`,
|
||||
scopes: ['esi-assets.read_assets.v1'],
|
||||
loginRole: index === 0 ? 'primary' : 'data_only',
|
||||
authorizationStatus: 'active',
|
||||
grantRevision: 1,
|
||||
credentialVersion: 1,
|
||||
authorizationEpoch: 1,
|
||||
datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'demo', syncedAt: now }],
|
||||
assets: { status: 'ready', syncedAt: now, expiresAt: now + 3_600_000 }
|
||||
}));
|
||||
const asset = (characterId: number, itemId: string, typeId: number, quantity: number, extra = {}): Asset => ({
|
||||
@@ -94,7 +103,10 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn
|
||||
if (previous.characterId !== characterId) throw new Error('Character authorization mismatch');
|
||||
if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous;
|
||||
const grant = await sso.refresh(previous.refreshToken);
|
||||
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) throw new Error('Character ownership changed; reconnect required');
|
||||
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) {
|
||||
await store.quarantineAuthorization(userId, characterId).catch(() => {});
|
||||
throw new Error('Character ownership changed; reconnect required');
|
||||
}
|
||||
const saved = await store.saveTokens(userId, characterId, { encryptedTokens: await cipher.seal(grant, `character:${characterId}`), tokenExpiresAt: grant.expiresAt, scopes: grant.scopes, expectedVersion: record.version });
|
||||
if (!saved) throw new Error('Authorization changed concurrently; retry synchronization');
|
||||
return grant;
|
||||
@@ -112,13 +124,15 @@ export async function synchronize(userId: string) {
|
||||
const characters = await store.characters(userId);
|
||||
const failed: number[] = []; let completed = 0;
|
||||
for (const character of characters) {
|
||||
if (character.authorizationStatus !== 'active') { failed.push(character.id); continue; }
|
||||
if (!character.scopes.includes('esi-assets.read_assets.v1')) { failed.push(character.id); continue; }
|
||||
let generation: string | undefined;
|
||||
try {
|
||||
generation = await store.beginSync(userId, character.id);
|
||||
// Explicit synchronization revalidates revocation/owner continuity even when ESI assets remain cached.
|
||||
// Refresh first so the generation fence records the credential version
|
||||
// that actually authorizes the ESI request and publication.
|
||||
const grant = await characterGrant(userId, character.id, true);
|
||||
if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing');
|
||||
generation = await store.beginSync(userId, character.id);
|
||||
const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`);
|
||||
const chunks = Math.ceil(snapshot.assets.length / 250);
|
||||
for (let index = 0; index < chunks; index++) await store.stageAssets(userId, character.id, generation, index, snapshot.assets.slice(index * 250, (index + 1) * 250));
|
||||
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { SsoDiagnosticError } from './oauth-diagnostics';
|
||||
import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities';
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
customFetch,
|
||||
@@ -183,9 +184,20 @@ export class EveSso {
|
||||
this.verificationKey = options.verificationKey;
|
||||
}
|
||||
|
||||
async authorizationUrl(state: string, challenge: string): Promise<string> {
|
||||
async authorizationUrl(
|
||||
state: string,
|
||||
challenge: string,
|
||||
requestedScopes: readonly string[] = [ASSET_SCOPE],
|
||||
): Promise<string> {
|
||||
nonEmptyString(state, 'state');
|
||||
nonEmptyString(challenge, 'PKCE challenge');
|
||||
if (
|
||||
requestedScopes.length === 0 ||
|
||||
requestedScopes.some((scope) => typeof scope !== 'string' || !SERVER_SCOPE_ALLOWLIST.includes(scope) || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) ||
|
||||
new Set(requestedScopes).size !== requestedScopes.length
|
||||
) {
|
||||
invalid('Invalid EVE SSO requested scopes');
|
||||
}
|
||||
const discovery = await this.discovery();
|
||||
if (!discovery.codeChallengeMethods.includes('S256')) {
|
||||
invalid('EVE SSO does not support PKCE S256');
|
||||
@@ -195,7 +207,7 @@ export class EveSso {
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', this.clientId);
|
||||
url.searchParams.set('redirect_uri', this.redirectUri);
|
||||
url.searchParams.set('scope', ASSET_SCOPE);
|
||||
url.searchParams.set('scope', requestedScopes.join(' '));
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
+42
-1
@@ -14,6 +14,12 @@ export type OAuthStateRecord = {
|
||||
encryptedVerifier: string;
|
||||
expiresAt: number;
|
||||
returnTo: string;
|
||||
intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect';
|
||||
capability: string;
|
||||
requestedScopes: string[];
|
||||
approvedExistingScopes: string[];
|
||||
initiatingSessionHash?: string;
|
||||
targetCharacterId?: number;
|
||||
};
|
||||
|
||||
export type CompleteLoginInput = {
|
||||
@@ -26,6 +32,12 @@ export type CompleteLoginInput = {
|
||||
tokenExpiresAt: number;
|
||||
sessionHash: string;
|
||||
sessionExpiresAt: number;
|
||||
intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect';
|
||||
capability: string;
|
||||
requestedScopes: string[];
|
||||
approvedExistingScopes: string[];
|
||||
initiatingSessionHash?: string;
|
||||
targetCharacterId?: number;
|
||||
};
|
||||
|
||||
export type TokenRecord = {
|
||||
@@ -76,6 +88,9 @@ const MUTATION = {
|
||||
finishSync: "functions:bridgeFinishSync",
|
||||
failSync: "functions:bridgeFailSync",
|
||||
saveInteraction: "functions:bridgeSaveInteraction",
|
||||
setPrimary: "functions:bridgeSetPrimary",
|
||||
invalidateAuthorization: "functions:bridgeInvalidateAuthorization",
|
||||
quarantineAuthorization: "functions:bridgeQuarantineAuthorization",
|
||||
} as const;
|
||||
|
||||
function queryReference(name: string): any {
|
||||
@@ -141,6 +156,12 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
encryptedVerifier: string;
|
||||
expiresAt: number;
|
||||
returnTo: string;
|
||||
intent: OAuthStateRecord['intent'];
|
||||
capability: string;
|
||||
requestedScopes: string[];
|
||||
approvedExistingScopes: string[];
|
||||
initiatingSessionHash?: string;
|
||||
targetCharacterId?: number;
|
||||
}): Promise<void> {
|
||||
await this.mutation(MUTATION.createOAuth, input);
|
||||
}
|
||||
@@ -156,16 +177,30 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
}
|
||||
|
||||
public async completeLogin(input: CompleteLoginInput): Promise<{ userId: string; sessionId: string }> {
|
||||
return await this.mutation<{ userId: string; sessionId: string }>(
|
||||
const result = await this.mutation<{ userId: string; sessionId: string; rejected?: string }>(
|
||||
MUTATION.completeLogin,
|
||||
input,
|
||||
);
|
||||
if (result.rejected === 'ownership_conflict') throw new Error('Character ownership conflict');
|
||||
return result;
|
||||
}
|
||||
|
||||
public async tokenRecord(userId: string, characterId: number): Promise<TokenRecord | null> {
|
||||
return await this.query<TokenRecord | null>(QUERY.tokenRecord, { userId, characterId });
|
||||
}
|
||||
|
||||
public async setPrimary(userId: string, characterId: number, initiatingSessionHash: string): Promise<void> {
|
||||
await this.mutation(MUTATION.setPrimary, { userId, characterId, initiatingSessionHash });
|
||||
}
|
||||
|
||||
public async invalidateAuthorization(userId: string, characterId: number, initiatingSessionHash: string): Promise<void> {
|
||||
await this.mutation(MUTATION.invalidateAuthorization, { userId, characterId, initiatingSessionHash });
|
||||
}
|
||||
|
||||
public async quarantineAuthorization(userId: string, characterId: number): Promise<void> {
|
||||
await this.mutation(MUTATION.quarantineAuthorization, { userId, characterId });
|
||||
}
|
||||
|
||||
public async saveTokens(
|
||||
userId: string,
|
||||
characterId: number,
|
||||
@@ -190,6 +225,12 @@ export class ConvexStore implements CharacterAssetRepository {
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
scopes: row.scopes,
|
||||
loginRole: row.loginRole,
|
||||
authorizationStatus: row.authorizationStatus,
|
||||
grantRevision: row.grantRevision,
|
||||
credentialVersion: row.credentialVersion,
|
||||
authorizationEpoch: row.authorizationEpoch,
|
||||
datasets: row.datasetStatuses,
|
||||
assets: {
|
||||
status: row.state,
|
||||
...(row.syncedAt === undefined ? {} : { syncedAt: row.syncedAt }),
|
||||
|
||||
@@ -5,22 +5,99 @@ import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
||||
import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security';
|
||||
import { protectMutation } from '$lib/server/http';
|
||||
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
|
||||
import { CAPABILITY_DEFINITIONS, SERVER_SCOPE_ALLOWLIST, validateCapabilityScopes } from '$lib/domain/capabilities';
|
||||
import type { Capability } from '$lib/domain/model';
|
||||
|
||||
const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dataset', 'reconnect']);
|
||||
const CONSENT_CAPABILITIES = new Set<Capability>([
|
||||
'asset_search',
|
||||
'asset_valuation',
|
||||
'wallet_balance',
|
||||
'manufacturing_opportunities',
|
||||
]);
|
||||
|
||||
function formString(value: FormDataEntryValue | null): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const config = configuration();
|
||||
protectMutation(event, `oauth:${event.getClientAddress()}`, 10);
|
||||
if (!capabilityEnabled('identity', config.capabilityAllowlist)) error(503, capabilityDisabledReason('identity'));
|
||||
if (config.mode === 'demo') error(400, 'EVE authentication is disabled in fixture mode');
|
||||
|
||||
let destination: string;
|
||||
try {
|
||||
const { store, cipher, sso } = live();
|
||||
const form = await event.request.formData();
|
||||
const capabilityValue = formString(form.get('capability')) ?? 'asset_search';
|
||||
if (!CONSENT_CAPABILITIES.has(capabilityValue as Capability)) error(400, 'Unsupported consent capability');
|
||||
const capability = capabilityValue as Capability;
|
||||
// Domain capability and deployment release gate are separate controls.
|
||||
if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
|
||||
&& !capabilityEnabled(capability, config.capabilityAllowlist)) {
|
||||
error(503, capabilityDisabledReason(capability));
|
||||
}
|
||||
|
||||
const current = await session(event);
|
||||
const state = randomSecret(); const binding = randomSecret(); const verifier = randomSecret();
|
||||
const rawCookie = event.cookies.get(SESSION_COOKIE);
|
||||
const targetValue = formString(form.get('targetCharacterId'));
|
||||
const targetCharacterId = targetValue === undefined ? undefined : Number(targetValue);
|
||||
if (targetCharacterId !== undefined && (!Number.isSafeInteger(targetCharacterId) || targetCharacterId <= 0)) {
|
||||
error(400, 'Invalid character selection');
|
||||
}
|
||||
const requestedIntent = formString(form.get('intent'));
|
||||
if (requestedIntent !== undefined && !CONSENT_INTENTS.has(requestedIntent)) error(400, 'Invalid consent intent');
|
||||
const intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' = (requestedIntent as 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' | undefined)
|
||||
?? (current ? targetCharacterId === undefined ? 'link_character' : 'reconnect' : 'initial_login');
|
||||
if (current === null && intent !== 'initial_login') error(401, 'An authenticated session is required for this action');
|
||||
if (intent === 'upgrade_dataset' || intent === 'reconnect') {
|
||||
if (!current || targetCharacterId === undefined) error(400, 'A linked character is required for this action');
|
||||
}
|
||||
|
||||
const { store, cipher, sso } = live();
|
||||
const owned = current ? await store.characters(current.userId) : [];
|
||||
const target = targetCharacterId === undefined ? undefined : owned.find((character) => character.id === targetCharacterId);
|
||||
if (targetCharacterId !== undefined && !target) error(403, 'Character is not linked to this application user');
|
||||
const approvedExistingScopes = target?.scopes.filter((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope)) ?? [];
|
||||
const requestedScopes = [...new Set([
|
||||
...CAPABILITY_DEFINITIONS[capability].requiredScopes,
|
||||
...approvedExistingScopes,
|
||||
])];
|
||||
const validation = validateCapabilityScopes(capability, requestedScopes, approvedExistingScopes);
|
||||
if (!validation.valid) error(400, 'Requested consent scopes are not permitted');
|
||||
|
||||
const state = randomSecret();
|
||||
const binding = randomSecret();
|
||||
const verifier = randomSecret();
|
||||
const stateHash = hashSecret(state);
|
||||
const encryptedVerifier = await cipher.seal({ verifier, sessionHash: current ? hashSecret(event.cookies.get(SESSION_COOKIE) ?? '') : null }, `oauth:${stateHash}`);
|
||||
await store.createOAuth({ stateHash, bindingHash: hashSecret(binding), ...(current ? { userId: current.userId } : {}), encryptedVerifier, expiresAt: Date.now() + 600_000, returnTo: '/' });
|
||||
destination = await sso.authorizationUrl(state, pkceChallenge(verifier));
|
||||
event.cookies.set('eve_oauth_binding', binding, { path: '/auth/eve', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 600 });
|
||||
} catch { error(503, 'EVE connection unavailable. Check server configuration.'); }
|
||||
redirect(303, destination);
|
||||
const initiatingSessionHash = current && rawCookie ? hashSecret(rawCookie) : undefined;
|
||||
const encryptedVerifier = await cipher.seal({
|
||||
verifier,
|
||||
sessionHash: initiatingSessionHash ?? null,
|
||||
}, `oauth:${stateHash}`);
|
||||
await store.createOAuth({
|
||||
stateHash,
|
||||
bindingHash: hashSecret(binding),
|
||||
...(current ? { userId: current.userId } : {}),
|
||||
encryptedVerifier,
|
||||
expiresAt: Date.now() + 600_000,
|
||||
returnTo: '/characters',
|
||||
intent,
|
||||
capability,
|
||||
requestedScopes,
|
||||
approvedExistingScopes,
|
||||
...(initiatingSessionHash ? { initiatingSessionHash } : {}),
|
||||
...(targetCharacterId === undefined ? {} : { targetCharacterId }),
|
||||
});
|
||||
destination = await sso.authorizationUrl(state, pkceChallenge(verifier), requestedScopes);
|
||||
event.cookies.set('eve_oauth_binding', binding, {
|
||||
path: '/auth/eve', httpOnly: true, sameSite: 'lax',
|
||||
secure: config.origin.startsWith('https:'), maxAge: 600,
|
||||
});
|
||||
} catch (caught) {
|
||||
// SvelteKit HTTP errors should retain their fixed status/message.
|
||||
if (caught && typeof caught === 'object' && 'status' in caught) throw caught;
|
||||
error(503, 'EVE connection unavailable. Check server configuration.');
|
||||
}
|
||||
redirect(303, destination!);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@ import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server
|
||||
import { oauthFailureDiagnostic, type OAuthPhase } from '$lib/server/oauth-diagnostics';
|
||||
import { configuration } from '$lib/server/config';
|
||||
import { capabilityEnabled } from '$lib/server/release-gate';
|
||||
import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities';
|
||||
|
||||
function safeReturnTo(value: string): string {
|
||||
return value === '/characters' ? value : '/characters';
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const config = configuration();
|
||||
@@ -12,6 +17,7 @@ export const GET: RequestHandler = async (event) => {
|
||||
const binding = event.cookies.get('eve_oauth_binding');
|
||||
event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' });
|
||||
let phase: OAuthPhase = 'state_input';
|
||||
let returnTo = '/characters';
|
||||
try {
|
||||
const state = event.url.searchParams.get('state');
|
||||
if (!state) throw new Error('Missing OAuth state');
|
||||
@@ -23,6 +29,7 @@ export const GET: RequestHandler = async (event) => {
|
||||
phase = 'state_consumption';
|
||||
const pending = await store.consumeOAuth(stateHash, hashSecret(binding));
|
||||
if (!pending || pending.expiresAt <= Date.now()) throw new Error('Expired OAuth state');
|
||||
returnTo = safeReturnTo(pending.returnTo);
|
||||
phase = 'provider_response';
|
||||
const code = event.url.searchParams.get('code');
|
||||
if (event.url.searchParams.has('error') || !code || code.length > 4096) throw new Error('Authorization denied');
|
||||
@@ -31,25 +38,49 @@ export const GET: RequestHandler = async (event) => {
|
||||
phase = 'session_binding';
|
||||
const current = await session(event);
|
||||
const oldCookie = event.cookies.get(SESSION_COOKIE);
|
||||
assertOAuthSessionBinding({ expectedUserId: pending.userId, expectedSessionHash: proof.sessionHash, currentUserId: current?.userId ?? null, currentCookie: oldCookie });
|
||||
assertOAuthSessionBinding({
|
||||
expectedUserId: pending.userId,
|
||||
expectedSessionHash: pending.initiatingSessionHash ?? proof.sessionHash,
|
||||
currentUserId: current?.userId ?? null,
|
||||
currentCookie: oldCookie,
|
||||
});
|
||||
phase = 'exchange';
|
||||
const grant = await sso.exchange(code, proof.verifier);
|
||||
if (grant.scopes.some((scope) => !SERVER_SCOPE_ALLOWLIST.includes(scope)) || new Set(grant.scopes).size !== grant.scopes.length) {
|
||||
throw new Error('Invalid EVE SSO granted scopes');
|
||||
}
|
||||
if (pending.targetCharacterId !== undefined && pending.targetCharacterId !== grant.characterId) {
|
||||
throw new Error('OAuth character mismatch');
|
||||
}
|
||||
const cookie = randomSecret();
|
||||
phase = 'token_encryption';
|
||||
const encryptedTokens = await cipher.seal(grant, `character:${grant.characterId}`);
|
||||
phase = 'login_persistence';
|
||||
await store.completeLogin({
|
||||
...(pending.userId ? { userId: pending.userId } : {}), characterId: grant.characterId, name: grant.name, ownerHash: grant.ownerHash, scopes: grant.scopes,
|
||||
encryptedTokens, tokenExpiresAt: grant.expiresAt,
|
||||
sessionHash: hashSecret(cookie), sessionExpiresAt: Date.now() + 7 * 86400_000
|
||||
...(pending.userId ? { userId: pending.userId } : {}),
|
||||
characterId: grant.characterId,
|
||||
name: grant.name,
|
||||
ownerHash: grant.ownerHash,
|
||||
scopes: grant.scopes,
|
||||
encryptedTokens,
|
||||
tokenExpiresAt: grant.expiresAt,
|
||||
sessionHash: hashSecret(cookie),
|
||||
sessionExpiresAt: Date.now() + 7 * 86400_000,
|
||||
intent: pending.intent,
|
||||
capability: pending.capability,
|
||||
requestedScopes: pending.requestedScopes,
|
||||
approvedExistingScopes: pending.approvedExistingScopes,
|
||||
...(pending.initiatingSessionHash ? { initiatingSessionHash: pending.initiatingSessionHash } : {}),
|
||||
...(pending.targetCharacterId === undefined ? {} : { targetCharacterId: pending.targetCharacterId }),
|
||||
});
|
||||
phase = 'session_revocation';
|
||||
if (oldCookie) await store.deleteSession(hashSecret(oldCookie));
|
||||
phase = 'cookie_issue';
|
||||
event.cookies.set(SESSION_COOKIE, cookie, { path: '/', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 7 * 86400 });
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify(oauthFailureDiagnostic(phase, error)));
|
||||
event.cookies.set(SESSION_COOKIE, cookie, {
|
||||
path: '/', httpOnly: true, sameSite: 'lax',
|
||||
secure: config.origin.startsWith('https:'), maxAge: 7 * 86400,
|
||||
});
|
||||
} catch (caught) {
|
||||
console.error(JSON.stringify(oauthFailureDiagnostic(phase, caught)));
|
||||
redirect(303, '/characters?auth=failed');
|
||||
}
|
||||
redirect(303, '/');
|
||||
redirect(303, returnTo);
|
||||
};
|
||||
|
||||
@@ -10,15 +10,23 @@ export const load: PageServerLoad = async (event) => {
|
||||
return {
|
||||
characters: [],
|
||||
identityEnabled: false,
|
||||
walletEnabled: false,
|
||||
error: capabilityDisabledReason('identity'),
|
||||
};
|
||||
}
|
||||
const current = await session(event);
|
||||
const authStatus = event.url.searchParams.get('auth');
|
||||
const error = authStatus === 'failed'
|
||||
? 'The character action failed or was cancelled. The login state may have expired, the character may be ineligible, or it may belong to another application user.'
|
||||
: authStatus === 'required'
|
||||
? 'Sign in with the primary character before managing linked characters.'
|
||||
: authStatus === 'disabled'
|
||||
? capabilityDisabledReason('identity')
|
||||
: null;
|
||||
return {
|
||||
characters: await charactersFor(current?.userId ?? null),
|
||||
identityEnabled: true,
|
||||
error: event.url.searchParams.get('auth') === 'failed'
|
||||
? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.'
|
||||
: null,
|
||||
walletEnabled: capabilityEnabled('wallet_balance', config.capabilityAllowlist),
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
<script lang="ts">
|
||||
import LiveCharacters from '$lib/components/LiveCharacters.svelte';
|
||||
let { data } = $props();
|
||||
const datasetLabel = (dataset: string) => dataset.replaceAll('_', ' ');
|
||||
</script>
|
||||
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
|
||||
<p class="muted">Connect each character explicitly. ESI does not expose all characters on an EVE account.</p>
|
||||
{#if data.mode !== 'demo' && data.identityEnabled}<form method="POST" action="/auth/eve"><button>Connect EVE Character</button></form>{/if}
|
||||
<p class="muted">Connect each character explicitly. One primary character is the only signed-out login; linked characters are data-only until you switch the primary while authenticated.</p>
|
||||
{#if data.mode !== 'demo' && data.identityEnabled}
|
||||
<form method="POST" action="/auth/eve">
|
||||
<input type="hidden" name="capability" value="asset_search" />
|
||||
<button>Connect EVE Character</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if data.error}<p class="error" role="alert">{data.error}</p>{/if}
|
||||
{#if data.userId && data.convexUrl && data.mode !== 'demo'}<LiveCharacters />{/if}
|
||||
{#each data.characters as character (character.id)}
|
||||
<section class="panel"><div class="row spread"><h2>{character.name}</h2><span class="muted">{character.assets.status}</span></div>
|
||||
<small>Character {character.id}</small>
|
||||
<p class="muted">Assets synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}<br />Granted scopes: {character.scopes.join(', ') || 'None'}</p>
|
||||
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
|
||||
<section class="panel">
|
||||
<div class="row spread"><h2>{character.name}</h2><span class="muted">{character.loginRole === 'data_only' ? 'DATA-ONLY' : 'PRIMARY'} · {character.authorizationStatus ?? 'active'}</span></div>
|
||||
<small>Character {character.id} · grant r{character.grantRevision ?? 1} · credential v{character.credentialVersion ?? 1} · epoch {character.authorizationEpoch ?? 1}</small>
|
||||
<p class="muted">Assets: {character.assets.status} · synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}<br />Granted scopes: {character.scopes.join(', ') || 'None'}</p>
|
||||
{#if character.datasets}
|
||||
<p class="muted">Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}</p>
|
||||
{/if}
|
||||
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
|
||||
{#if data.mode !== 'demo' && data.identityEnabled}
|
||||
<div class="row">
|
||||
{#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'}
|
||||
<form method="POST" action="/characters/primary"><input type="hidden" name="characterId" value={character.id} /><button class="quiet">Set as primary</button></form>
|
||||
{/if}
|
||||
<form method="POST" action="/auth/eve">
|
||||
<input type="hidden" name="capability" value="asset_search" />
|
||||
<input type="hidden" name="intent" value="reconnect" />
|
||||
<input type="hidden" name="targetCharacterId" value={character.id} />
|
||||
<button class="quiet">{character.authorizationStatus === 'active' ? 'Refresh authorization' : 'Reconnect'}</button>
|
||||
</form>
|
||||
{#if character.authorizationStatus === 'active' && data.walletEnabled}
|
||||
<form method="POST" action="/auth/eve">
|
||||
<input type="hidden" name="capability" value="wallet_balance" />
|
||||
<input type="hidden" name="intent" value="upgrade_dataset" />
|
||||
<input type="hidden" name="targetCharacterId" value={character.id} />
|
||||
<button class="quiet">Authorize wallet</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if character.loginRole === 'data_only' && character.authorizationStatus !== 'revoked'}
|
||||
<details>
|
||||
<summary>Disconnect character</summary>
|
||||
<p class="muted">This immediately blocks local datasets, discards the stored credential, and cancels pending local work. Reconnecting requires EVE authorization again.</p>
|
||||
<form method="POST" action="/characters/invalidate">
|
||||
<input type="hidden" name="characterId" value={character.id} />
|
||||
<button class="quiet">Confirm disconnect</button>
|
||||
</form>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}<section class="panel"><p>No connected characters. Your first verified EVE login creates your application user; further connections attach to that user.</p></section>{/each}
|
||||
{:else}<section class="panel"><p>No connected characters. Your first verified EVE login creates the primary application character.</p></section>{/each}
|
||||
<p><a href="/assets">Open asset report →</a></p>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
||||
import { hashSecret } from '$lib/server/security';
|
||||
import { protectMutation } from '$lib/server/http';
|
||||
import { configuration } from '$lib/server/config';
|
||||
import { capabilityEnabled } from '$lib/server/release-gate';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const config = configuration();
|
||||
protectMutation(event, `character-invalidate:${event.getClientAddress()}`, 20);
|
||||
if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled');
|
||||
const current = await session(event);
|
||||
if (!current) redirect(303, '/characters?auth=required');
|
||||
const form = await event.request.formData();
|
||||
const value = form.get('characterId');
|
||||
const characterId = typeof value === 'string' ? Number(value) : NaN;
|
||||
if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed');
|
||||
const cookie = event.cookies.get(SESSION_COOKIE);
|
||||
if (!cookie) redirect(303, '/characters?auth=required');
|
||||
try {
|
||||
await live().store.invalidateAuthorization(current.userId, characterId, hashSecret(cookie));
|
||||
} catch {
|
||||
redirect(303, '/characters?auth=failed');
|
||||
}
|
||||
redirect(303, '/characters');
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { configuration } from '$lib/server/config';
|
||||
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
||||
import { hashSecret } from '$lib/server/security';
|
||||
import { protectMutation } from '$lib/server/http';
|
||||
import { capabilityEnabled } from '$lib/server/release-gate';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const config = configuration();
|
||||
protectMutation(event, `character-primary:${event.getClientAddress()}`, 20);
|
||||
if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled');
|
||||
const current = await session(event);
|
||||
if (!current) redirect(303, '/characters?auth=required');
|
||||
const form = await event.request.formData();
|
||||
const value = form.get('characterId');
|
||||
const characterId = typeof value === 'string' ? Number(value) : NaN;
|
||||
if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed');
|
||||
const cookie = event.cookies.get(SESSION_COOKIE);
|
||||
if (!cookie) redirect(303, '/characters?auth=required');
|
||||
try {
|
||||
await live().store.setPrimary(current.userId, characterId, hashSecret(cookie));
|
||||
} catch {
|
||||
redirect(303, '/characters?auth=failed');
|
||||
}
|
||||
redirect(303, '/characters');
|
||||
};
|
||||
Reference in New Issue
Block a user