1063 lines
31 KiB
TypeScript
1063 lines
31 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import type { Pool } from "pg";
|
|
|
|
import { allowedAgentScopes, type AgentScope } from "../domain/scopes.js";
|
|
import { hashAgentSetupCode, hashAgentToken } from "../security/agent-access.js";
|
|
|
|
const allowedScopeSet = new Set<string>(allowedAgentScopes);
|
|
|
|
export type AgentStatus = "active" | "disabled" | "revoked";
|
|
export type AgentGrantMode = "voluntary" | "reporting";
|
|
export type AgentSetupCodeStatus = "active" | "used" | "expired" | "revoked";
|
|
export type AgentTokenStatus = "active" | "revoked" | "expired";
|
|
export type AgentTokenGrantScope = "agent" | "token";
|
|
|
|
export type AgentRecord = {
|
|
id: string;
|
|
ownerUserId: string;
|
|
ownerEmail: string | null;
|
|
displayName: string;
|
|
avatarUrl: string | null;
|
|
status: AgentStatus;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type AgentGrantRecord = {
|
|
id: string;
|
|
agentId: string;
|
|
workspaceSlug: string;
|
|
projectId: string | null;
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
createdByUserId: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type AgentTokenRecord = {
|
|
id: string;
|
|
agentId: string;
|
|
name: string;
|
|
status: AgentTokenStatus;
|
|
grantScope: AgentTokenGrantScope;
|
|
tokenSuffix: string | null;
|
|
expiresAt: string | null;
|
|
lastUsedAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type AgentSessionRecord = {
|
|
agent: AgentRecord;
|
|
token: AgentTokenRecord;
|
|
grants: AgentGrantRecord[];
|
|
grantSource: AgentTokenGrantScope;
|
|
};
|
|
|
|
export type AgentSetupCodeRecord = {
|
|
id: string;
|
|
agentId: string;
|
|
codeSuffix: string | null;
|
|
status: AgentSetupCodeStatus;
|
|
tokenName: string;
|
|
createdByUserId: string | null;
|
|
expiresAt: string;
|
|
usedAt: string | null;
|
|
usedTokenId: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AgentRow = {
|
|
id: string;
|
|
owner_user_id: string;
|
|
owner_email: string | null;
|
|
display_name: string;
|
|
avatar_url: string | null;
|
|
status: AgentStatus;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type GrantRow = {
|
|
id: string;
|
|
agent_id: string;
|
|
workspace_slug: string;
|
|
project_id: string;
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
created_by_user_id: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type TokenRow = {
|
|
id: string;
|
|
agent_id: string;
|
|
name: string;
|
|
status: AgentTokenStatus;
|
|
grant_scope: AgentTokenGrantScope;
|
|
token_suffix: string | null;
|
|
expires_at: Date | null;
|
|
last_used_at: Date | null;
|
|
created_at: Date;
|
|
};
|
|
|
|
type SetupCodeRow = {
|
|
id: string;
|
|
agent_id: string;
|
|
code_hash: string;
|
|
code_suffix: string | null;
|
|
status: AgentSetupCodeStatus;
|
|
token_name: string;
|
|
created_by_user_id: string | null;
|
|
expires_at: Date;
|
|
used_at: Date | null;
|
|
used_token_id: string | null;
|
|
created_at: Date;
|
|
};
|
|
|
|
type SessionRow = AgentRow & {
|
|
token_id: string;
|
|
token_name: string;
|
|
token_status: AgentTokenStatus;
|
|
token_grant_scope: AgentTokenGrantScope;
|
|
token_suffix: string | null;
|
|
token_expires_at: Date | null;
|
|
token_last_used_at: Date | null;
|
|
token_created_at: Date;
|
|
};
|
|
|
|
type TokenGrantRow = {
|
|
id: string;
|
|
token_id: string;
|
|
agent_id: string;
|
|
workspace_slug: string;
|
|
project_id: string;
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
created_by_user_id: string;
|
|
source_grant_id: string | null;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type IdempotencyRow = {
|
|
key: string;
|
|
agent_id: string;
|
|
request_hash: string;
|
|
response_body: unknown | null;
|
|
status: "processing" | "completed";
|
|
created_at: Date;
|
|
expires_at: Date;
|
|
locked_until: Date | null;
|
|
};
|
|
|
|
export type CreateAgentInput = {
|
|
ownerUserId: string;
|
|
ownerEmail?: string | null;
|
|
displayName: string;
|
|
avatarUrl?: string | null;
|
|
};
|
|
|
|
export type UpdateAgentProfileInput = {
|
|
displayName?: string;
|
|
avatarUrl?: string | null;
|
|
actorUserId?: string;
|
|
};
|
|
|
|
export type UpsertGrantInput = {
|
|
workspaceSlug: string;
|
|
projectId?: string | null;
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
createdByUserId: string;
|
|
};
|
|
|
|
export type ReplaceWorkspaceGrantsInput = {
|
|
workspaceSlug: string;
|
|
projectIds: string[];
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
actorUserId: string;
|
|
};
|
|
|
|
export type ReplaceProjectGrantTarget = {
|
|
workspaceSlug: string;
|
|
projectId: string;
|
|
};
|
|
|
|
export type ReplaceProjectGrantsInput = {
|
|
grants: ReplaceProjectGrantTarget[];
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
actorUserId: string;
|
|
};
|
|
|
|
export type CreateTokenInput = {
|
|
token: string;
|
|
name: string;
|
|
expiresAt?: string | null;
|
|
tokenGrants?: CreateTokenGrantInput[];
|
|
};
|
|
|
|
export type CreateSetupCodeInput = {
|
|
code: string;
|
|
createdByUserId?: string | null;
|
|
expiresAt: string;
|
|
tokenName: string;
|
|
};
|
|
|
|
export type CreateTokenGrantInput = {
|
|
workspaceSlug: string;
|
|
projectId?: string | null;
|
|
scopes: AgentScope[];
|
|
mode: AgentGrantMode;
|
|
createdByUserId: string;
|
|
sourceGrantId?: string | null;
|
|
};
|
|
|
|
export type IdempotencyClaim =
|
|
| {
|
|
status: "claimed";
|
|
}
|
|
| {
|
|
status: "replay";
|
|
responseBody: unknown;
|
|
}
|
|
| {
|
|
status: "in_progress";
|
|
lockedUntil: string | null;
|
|
}
|
|
| {
|
|
status: "conflict";
|
|
};
|
|
|
|
export type RedeemSetupCodeResult =
|
|
| {
|
|
status: "redeemed";
|
|
agent: AgentRecord;
|
|
token: AgentTokenRecord;
|
|
grants: AgentGrantRecord[];
|
|
setupCode: AgentSetupCodeRecord;
|
|
}
|
|
| {
|
|
status: "invalid" | "expired" | "used" | "revoked";
|
|
};
|
|
|
|
export class AgentsRepository {
|
|
constructor(private readonly pool: Pool) {}
|
|
|
|
async createAgent(input: CreateAgentInput): Promise<AgentRecord> {
|
|
const result = await this.pool.query<AgentRow>(
|
|
`
|
|
INSERT INTO agents(id, owner_user_id, owner_email, display_name, avatar_url)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *
|
|
`,
|
|
[randomUUID(), input.ownerUserId, input.ownerEmail ?? null, input.displayName, input.avatarUrl ?? null]
|
|
);
|
|
|
|
return mapAgent(result.rows[0]);
|
|
}
|
|
|
|
async listAgents(ownerUserId?: string): Promise<AgentRecord[]> {
|
|
const result = ownerUserId
|
|
? await this.pool.query<AgentRow>("SELECT * FROM agents WHERE owner_user_id = $1 ORDER BY created_at DESC", [ownerUserId])
|
|
: await this.pool.query<AgentRow>("SELECT * FROM agents ORDER BY created_at DESC");
|
|
|
|
return result.rows.map(mapAgent);
|
|
}
|
|
|
|
async listAgentsByOwnerIdentity(ownerUserId?: string, ownerEmail?: string): Promise<AgentRecord[]> {
|
|
const normalizedOwnerUserId = normalizeNullableText(ownerUserId);
|
|
const normalizedOwnerEmail = normalizeNullableText(ownerEmail)?.toLowerCase();
|
|
|
|
if (!normalizedOwnerUserId && !normalizedOwnerEmail) {
|
|
return [];
|
|
}
|
|
|
|
const result = await this.pool.query<AgentRow>(
|
|
`
|
|
SELECT *
|
|
FROM agents
|
|
WHERE ($1::text IS NOT NULL AND owner_user_id = $1)
|
|
OR ($2::text IS NOT NULL AND owner_email IS NOT NULL AND lower(owner_email) = $2)
|
|
ORDER BY created_at DESC
|
|
`,
|
|
[normalizedOwnerUserId, normalizedOwnerEmail]
|
|
);
|
|
|
|
return result.rows.map(mapAgent);
|
|
}
|
|
|
|
async getAgent(agentId: string): Promise<AgentRecord | null> {
|
|
const result = await this.pool.query<AgentRow>("SELECT * FROM agents WHERE id = $1", [agentId]);
|
|
return result.rows[0] ? mapAgent(result.rows[0]) : null;
|
|
}
|
|
|
|
async updateAgentProfile(agentId: string, input: UpdateAgentProfileInput): Promise<AgentRecord | null> {
|
|
const currentAgent = await this.getAgent(agentId);
|
|
|
|
if (!currentAgent) {
|
|
return null;
|
|
}
|
|
|
|
const displayName = input.displayName ?? currentAgent.displayName;
|
|
const avatarUrl = input.avatarUrl === undefined ? currentAgent.avatarUrl : input.avatarUrl;
|
|
const result = await this.pool.query<AgentRow>(
|
|
`
|
|
UPDATE agents
|
|
SET display_name = $2, avatar_url = $3, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *
|
|
`,
|
|
[agentId, displayName, avatarUrl]
|
|
);
|
|
|
|
await this.createAuditEvent(agentId, "agent.profile.updated", input.actorUserId, {
|
|
displayName,
|
|
avatarUrl,
|
|
});
|
|
|
|
return mapAgent(result.rows[0]);
|
|
}
|
|
|
|
async revokeAgent(agentId: string, actorUserId?: string): Promise<AgentRecord | null> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
const result = await client.query<AgentRow>(
|
|
`
|
|
UPDATE agents
|
|
SET status = 'revoked', updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *
|
|
`,
|
|
[agentId]
|
|
);
|
|
|
|
if (!result.rows[0]) {
|
|
await client.query("ROLLBACK");
|
|
return null;
|
|
}
|
|
|
|
await client.query("UPDATE agent_tokens SET status = 'revoked' WHERE agent_id = $1 AND status = 'active'", [agentId]);
|
|
await client.query(
|
|
"INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id) VALUES ($1, $2, $3)",
|
|
[agentId, "agent.revoked", actorUserId ?? null]
|
|
);
|
|
await client.query("COMMIT");
|
|
return mapAgent(result.rows[0]);
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async upsertGrant(agentId: string, input: UpsertGrantInput): Promise<AgentGrantRecord> {
|
|
assertAllowedScopes(input.scopes);
|
|
|
|
const projectId = input.projectId ?? "";
|
|
const result = await this.pool.query<GrantRow>(
|
|
`
|
|
INSERT INTO agent_grants(agent_id, workspace_slug, project_id, scopes, mode, created_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (agent_id, workspace_slug, project_id)
|
|
DO UPDATE SET
|
|
scopes = EXCLUDED.scopes,
|
|
mode = EXCLUDED.mode,
|
|
created_by_user_id = EXCLUDED.created_by_user_id,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`,
|
|
[agentId, input.workspaceSlug, projectId, input.scopes, input.mode, input.createdByUserId]
|
|
);
|
|
|
|
await this.createAuditEvent(agentId, "agent.grant.upserted", input.createdByUserId, {
|
|
workspaceSlug: input.workspaceSlug,
|
|
projectId: input.projectId ?? null,
|
|
scopes: input.scopes,
|
|
mode: input.mode,
|
|
});
|
|
|
|
return mapGrant(result.rows[0]);
|
|
}
|
|
|
|
async listGrants(agentId: string): Promise<AgentGrantRecord[]> {
|
|
const result = await this.pool.query<GrantRow>("SELECT * FROM agent_grants WHERE agent_id = $1 ORDER BY created_at DESC", [agentId]);
|
|
return result.rows.map(mapGrant);
|
|
}
|
|
|
|
async replaceWorkspaceProjectGrants(agentId: string, input: ReplaceWorkspaceGrantsInput): Promise<AgentGrantRecord[]> {
|
|
assertAllowedScopes(input.scopes);
|
|
|
|
const projectIds = [...new Set(input.projectIds.map((projectId) => projectId.trim()).filter(Boolean))];
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query(
|
|
`
|
|
DELETE FROM agent_grants
|
|
WHERE agent_id = $1
|
|
AND workspace_slug = $2
|
|
AND NOT (project_id = ANY($3::text[]))
|
|
`,
|
|
[agentId, input.workspaceSlug, projectIds]
|
|
);
|
|
|
|
const grants: AgentGrantRecord[] = [];
|
|
for (const projectId of projectIds) {
|
|
const result = await client.query<GrantRow>(
|
|
`
|
|
INSERT INTO agent_grants(agent_id, workspace_slug, project_id, scopes, mode, created_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (agent_id, workspace_slug, project_id)
|
|
DO UPDATE SET
|
|
scopes = EXCLUDED.scopes,
|
|
mode = EXCLUDED.mode,
|
|
created_by_user_id = EXCLUDED.created_by_user_id,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`,
|
|
[agentId, input.workspaceSlug, projectId, input.scopes, input.mode, input.actorUserId]
|
|
);
|
|
grants.push(mapGrant(result.rows[0]));
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata)
|
|
VALUES ($1, $2, $3, $4)
|
|
`,
|
|
[
|
|
agentId,
|
|
"agent.grants.replaced",
|
|
input.actorUserId,
|
|
{
|
|
workspaceSlug: input.workspaceSlug,
|
|
projectIds,
|
|
scopes: input.scopes,
|
|
mode: input.mode,
|
|
},
|
|
]
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
return grants;
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async replaceProjectGrants(agentId: string, input: ReplaceProjectGrantsInput): Promise<AgentGrantRecord[]> {
|
|
assertAllowedScopes(input.scopes);
|
|
|
|
const seenKeys = new Set<string>();
|
|
const grantTargets = input.grants
|
|
.map((grant) => ({
|
|
workspaceSlug: grant.workspaceSlug.trim(),
|
|
projectId: grant.projectId.trim(),
|
|
}))
|
|
.filter((grant) => grant.workspaceSlug && grant.projectId)
|
|
.filter((grant) => {
|
|
const key = `${grant.workspaceSlug}:${grant.projectId}`;
|
|
if (seenKeys.has(key)) {
|
|
return false;
|
|
}
|
|
seenKeys.add(key);
|
|
return true;
|
|
});
|
|
|
|
if (grantTargets.length === 0) {
|
|
throw new Error("At least one project grant is required.");
|
|
}
|
|
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query("DELETE FROM agent_grants WHERE agent_id = $1 AND project_id <> ''", [agentId]);
|
|
|
|
const grants: AgentGrantRecord[] = [];
|
|
for (const grantTarget of grantTargets) {
|
|
const result = await client.query<GrantRow>(
|
|
`
|
|
INSERT INTO agent_grants(agent_id, workspace_slug, project_id, scopes, mode, created_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (agent_id, workspace_slug, project_id)
|
|
DO UPDATE SET
|
|
scopes = EXCLUDED.scopes,
|
|
mode = EXCLUDED.mode,
|
|
created_by_user_id = EXCLUDED.created_by_user_id,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`,
|
|
[agentId, grantTarget.workspaceSlug, grantTarget.projectId, input.scopes, input.mode, input.actorUserId]
|
|
);
|
|
grants.push(mapGrant(result.rows[0]));
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata)
|
|
VALUES ($1, $2, $3, $4)
|
|
`,
|
|
[
|
|
agentId,
|
|
"agent.project_grants.replaced",
|
|
input.actorUserId,
|
|
{
|
|
grants: grantTargets,
|
|
scopes: input.scopes,
|
|
mode: input.mode,
|
|
},
|
|
]
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
return grants;
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async createToken(agentId: string, input: CreateTokenInput): Promise<AgentTokenRecord> {
|
|
const tokenGrants = input.tokenGrants ?? [];
|
|
const grantScope: AgentTokenGrantScope = tokenGrants.length > 0 ? "token" : "agent";
|
|
const client = await this.pool.connect();
|
|
let tokenRecord: AgentTokenRecord;
|
|
|
|
for (const grant of tokenGrants) {
|
|
assertAllowedScopes(grant.scopes);
|
|
}
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
const result = await client.query<TokenRow>(
|
|
`
|
|
INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at, grant_scope)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
|
|
`,
|
|
[agentId, hashAgentToken(input.token), input.token.slice(-8), input.name, input.expiresAt ?? null, grantScope]
|
|
);
|
|
tokenRecord = mapToken(result.rows[0]);
|
|
|
|
for (const grant of tokenGrants) {
|
|
await client.query(
|
|
`
|
|
INSERT INTO agent_token_grants(token_id, workspace_slug, project_id, scopes, mode, created_by_user_id, source_grant_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
`,
|
|
[
|
|
tokenRecord.id,
|
|
grant.workspaceSlug,
|
|
grant.projectId ?? "",
|
|
grant.scopes,
|
|
grant.mode,
|
|
grant.createdByUserId,
|
|
grant.sourceGrantId ?? null,
|
|
]
|
|
);
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
await this.createAuditEvent(agentId, "agent.token.created", undefined, {
|
|
tokenId: tokenRecord.id,
|
|
name: input.name,
|
|
expiresAt: input.expiresAt ?? null,
|
|
grantScope,
|
|
tokenGrantCount: tokenGrants.length,
|
|
});
|
|
|
|
return tokenRecord;
|
|
}
|
|
|
|
async createSetupCode(agentId: string, input: CreateSetupCodeInput): Promise<AgentSetupCodeRecord> {
|
|
const result = await this.pool.query<SetupCodeRow>(
|
|
`
|
|
INSERT INTO agent_setup_codes(agent_id, code_hash, code_suffix, token_name, created_by_user_id, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *
|
|
`,
|
|
[
|
|
agentId,
|
|
hashAgentSetupCode(input.code),
|
|
input.code.slice(-8),
|
|
input.tokenName,
|
|
input.createdByUserId ?? null,
|
|
input.expiresAt,
|
|
]
|
|
);
|
|
const setupCode = mapSetupCode(result.rows[0]);
|
|
|
|
await this.createAuditEvent(agentId, "agent.setup_code.created", input.createdByUserId ?? undefined, {
|
|
setupCodeId: setupCode.id,
|
|
codeSuffix: setupCode.codeSuffix,
|
|
expiresAt: setupCode.expiresAt,
|
|
tokenName: setupCode.tokenName,
|
|
});
|
|
|
|
return setupCode;
|
|
}
|
|
|
|
async redeemSetupCode(code: string, token: string): Promise<RedeemSetupCodeResult> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
const setupCodeResult = await client.query<SetupCodeRow>(
|
|
`
|
|
SELECT *
|
|
FROM agent_setup_codes
|
|
WHERE code_hash = $1
|
|
FOR UPDATE
|
|
`,
|
|
[hashAgentSetupCode(code)]
|
|
);
|
|
const setupCodeRow = setupCodeResult.rows[0];
|
|
|
|
if (!setupCodeRow) {
|
|
await client.query("COMMIT");
|
|
return { status: "invalid" };
|
|
}
|
|
|
|
if (setupCodeRow.status === "used" || setupCodeRow.used_at) {
|
|
await client.query("COMMIT");
|
|
return { status: "used" };
|
|
}
|
|
|
|
if (setupCodeRow.status === "revoked") {
|
|
await client.query("COMMIT");
|
|
return { status: "revoked" };
|
|
}
|
|
|
|
if (setupCodeRow.status !== "active" || setupCodeRow.expires_at <= new Date()) {
|
|
await client.query(
|
|
`
|
|
UPDATE agent_setup_codes
|
|
SET status = 'expired'
|
|
WHERE id = $1 AND status = 'active'
|
|
`,
|
|
[setupCodeRow.id]
|
|
);
|
|
await client.query(
|
|
`
|
|
INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata)
|
|
VALUES ($1, $2, $3, $4)
|
|
`,
|
|
[
|
|
setupCodeRow.agent_id,
|
|
"agent.setup_code.expired",
|
|
setupCodeRow.created_by_user_id,
|
|
{
|
|
setupCodeId: setupCodeRow.id,
|
|
codeSuffix: setupCodeRow.code_suffix,
|
|
},
|
|
]
|
|
);
|
|
await client.query("COMMIT");
|
|
return { status: "expired" };
|
|
}
|
|
|
|
const agentResult = await client.query<AgentRow>(
|
|
`
|
|
SELECT *
|
|
FROM agents
|
|
WHERE id = $1 AND status = 'active'
|
|
FOR UPDATE
|
|
`,
|
|
[setupCodeRow.agent_id]
|
|
);
|
|
const agentRow = agentResult.rows[0];
|
|
|
|
if (!agentRow) {
|
|
await client.query("COMMIT");
|
|
return { status: "revoked" };
|
|
}
|
|
|
|
const tokenResult = await client.query<TokenRow>(
|
|
`
|
|
INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at, grant_scope)
|
|
VALUES ($1, $2, $3, $4, NULL, 'agent')
|
|
RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
|
|
`,
|
|
[setupCodeRow.agent_id, hashAgentToken(token), token.slice(-8), setupCodeRow.token_name]
|
|
);
|
|
const tokenRow = tokenResult.rows[0];
|
|
|
|
const redeemedSetupCodeResult = await client.query<SetupCodeRow>(
|
|
`
|
|
UPDATE agent_setup_codes
|
|
SET status = 'used', used_at = now(), used_token_id = $2
|
|
WHERE id = $1
|
|
RETURNING *
|
|
`,
|
|
[setupCodeRow.id, tokenRow.id]
|
|
);
|
|
|
|
const grantResult = await client.query<GrantRow>(
|
|
"SELECT * FROM agent_grants WHERE agent_id = $1 ORDER BY created_at DESC",
|
|
[setupCodeRow.agent_id]
|
|
);
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata)
|
|
VALUES ($1, $2, $3, $4)
|
|
`,
|
|
[
|
|
setupCodeRow.agent_id,
|
|
"agent.setup_code.redeemed",
|
|
setupCodeRow.created_by_user_id,
|
|
{
|
|
setupCodeId: setupCodeRow.id,
|
|
codeSuffix: setupCodeRow.code_suffix,
|
|
tokenId: tokenRow.id,
|
|
tokenName: tokenRow.name,
|
|
},
|
|
]
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
return {
|
|
status: "redeemed",
|
|
agent: mapAgent(agentRow),
|
|
token: mapToken(tokenRow),
|
|
grants: grantResult.rows.map(mapGrant),
|
|
setupCode: mapSetupCode(redeemedSetupCodeResult.rows[0]),
|
|
};
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async listTokens(agentId: string): Promise<AgentTokenRecord[]> {
|
|
const result = await this.pool.query<TokenRow>(
|
|
`
|
|
SELECT id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
|
|
FROM agent_tokens
|
|
WHERE agent_id = $1
|
|
ORDER BY created_at DESC
|
|
`,
|
|
[agentId]
|
|
);
|
|
return result.rows.map(mapToken);
|
|
}
|
|
|
|
async revokeToken(agentId: string, tokenId: string, actorUserId?: string): Promise<AgentTokenRecord | null> {
|
|
const result = await this.pool.query<TokenRow>(
|
|
`
|
|
UPDATE agent_tokens
|
|
SET status = 'revoked'
|
|
WHERE agent_id = $1 AND id = $2
|
|
RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
|
|
`,
|
|
[agentId, tokenId]
|
|
);
|
|
|
|
if (!result.rows[0]) {
|
|
return null;
|
|
}
|
|
|
|
await this.createAuditEvent(agentId, "agent.token.revoked", actorUserId, { tokenId });
|
|
return mapToken(result.rows[0]);
|
|
}
|
|
|
|
async findActiveSessionByToken(token: string): Promise<AgentSessionRecord | null> {
|
|
const result = await this.pool.query<SessionRow>(
|
|
`
|
|
SELECT
|
|
agents.id,
|
|
agents.owner_user_id,
|
|
agents.owner_email,
|
|
agents.display_name,
|
|
agents.avatar_url,
|
|
agents.status,
|
|
agents.created_at,
|
|
agents.updated_at,
|
|
agent_tokens.id AS token_id,
|
|
agent_tokens.name AS token_name,
|
|
agent_tokens.status AS token_status,
|
|
agent_tokens.grant_scope AS token_grant_scope,
|
|
agent_tokens.token_suffix AS token_suffix,
|
|
agent_tokens.expires_at AS token_expires_at,
|
|
agent_tokens.last_used_at AS token_last_used_at,
|
|
agent_tokens.created_at AS token_created_at
|
|
FROM agent_tokens
|
|
JOIN agents ON agents.id = agent_tokens.agent_id
|
|
WHERE agent_tokens.token_hash = $1
|
|
AND agent_tokens.status = 'active'
|
|
AND agents.status = 'active'
|
|
AND (agent_tokens.expires_at IS NULL OR agent_tokens.expires_at > now())
|
|
`,
|
|
[hashAgentToken(token)]
|
|
);
|
|
|
|
const row = result.rows[0];
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const updatedToken = await this.pool.query<TokenRow>(
|
|
`
|
|
UPDATE agent_tokens
|
|
SET last_used_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
|
|
`,
|
|
[row.token_id]
|
|
);
|
|
|
|
return {
|
|
agent: mapAgent(row),
|
|
token: mapToken(updatedToken.rows[0]),
|
|
grants: row.token_grant_scope === "token" ? await this.listTokenGrants(row.token_id) : await this.listGrants(row.id),
|
|
grantSource: row.token_grant_scope,
|
|
};
|
|
}
|
|
|
|
async listTokenGrants(tokenId: string): Promise<AgentGrantRecord[]> {
|
|
const result = await this.pool.query<TokenGrantRow>(
|
|
`
|
|
SELECT
|
|
agent_token_grants.id,
|
|
agent_token_grants.token_id,
|
|
agent_tokens.agent_id,
|
|
agent_token_grants.workspace_slug,
|
|
agent_token_grants.project_id,
|
|
ARRAY(
|
|
SELECT token_scope
|
|
FROM unnest(agent_token_grants.scopes) AS token_scope
|
|
WHERE token_scope = ANY(COALESCE(agent_grants.scopes, agent_token_grants.scopes))
|
|
ORDER BY token_scope
|
|
) AS scopes,
|
|
COALESCE(agent_grants.mode, agent_token_grants.mode) AS mode,
|
|
agent_token_grants.created_by_user_id,
|
|
agent_token_grants.source_grant_id,
|
|
agent_token_grants.created_at,
|
|
agent_token_grants.updated_at
|
|
FROM agent_token_grants
|
|
JOIN agent_tokens ON agent_tokens.id = agent_token_grants.token_id
|
|
LEFT JOIN agent_grants ON agent_grants.id = agent_token_grants.source_grant_id
|
|
WHERE agent_token_grants.token_id = $1
|
|
AND (agent_token_grants.source_grant_id IS NULL OR agent_grants.id IS NOT NULL)
|
|
ORDER BY agent_token_grants.created_at DESC
|
|
`,
|
|
[tokenId]
|
|
);
|
|
|
|
return result.rows.map(mapTokenGrant);
|
|
}
|
|
|
|
async createAuditEvent(agentId: string | null, eventType: string, actorUserId?: string, metadata: Record<string, unknown> = {}): Promise<void> {
|
|
await this.pool.query(
|
|
`
|
|
INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata)
|
|
VALUES ($1, $2, $3, $4)
|
|
`,
|
|
[agentId, eventType, actorUserId ?? null, metadata]
|
|
);
|
|
}
|
|
|
|
async claimIdempotencyKey(agentId: string, idempotencyKey: string, requestHash: string): Promise<IdempotencyClaim> {
|
|
const storageKey = buildIdempotencyStorageKey(agentId, idempotencyKey);
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query("DELETE FROM idempotency_keys WHERE key = $1 AND expires_at <= now()", [storageKey]);
|
|
|
|
const inserted = await client.query<IdempotencyRow>(
|
|
`
|
|
INSERT INTO idempotency_keys(key, agent_id, request_hash, response_body, status, expires_at, locked_until)
|
|
VALUES ($1, $2, $3, NULL, 'processing', now() + interval '24 hours', now() + interval '5 minutes')
|
|
ON CONFLICT (key) DO NOTHING
|
|
RETURNING *
|
|
`,
|
|
[storageKey, agentId, requestHash]
|
|
);
|
|
|
|
if (inserted.rows[0]) {
|
|
await client.query("COMMIT");
|
|
return { status: "claimed" };
|
|
}
|
|
|
|
const existing = await client.query<IdempotencyRow>("SELECT * FROM idempotency_keys WHERE key = $1 AND agent_id = $2 FOR UPDATE", [
|
|
storageKey,
|
|
agentId,
|
|
]);
|
|
const row = existing.rows[0];
|
|
|
|
if (!row) {
|
|
await client.query("COMMIT");
|
|
return { status: "conflict" };
|
|
}
|
|
|
|
if (row.request_hash !== requestHash) {
|
|
await client.query("COMMIT");
|
|
return { status: "conflict" };
|
|
}
|
|
|
|
if (row.status === "completed") {
|
|
await client.query("COMMIT");
|
|
return {
|
|
status: "replay",
|
|
responseBody: row.response_body,
|
|
};
|
|
}
|
|
|
|
if (row.locked_until && row.locked_until > new Date()) {
|
|
await client.query("COMMIT");
|
|
return {
|
|
status: "in_progress",
|
|
lockedUntil: row.locked_until.toISOString(),
|
|
};
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE idempotency_keys
|
|
SET locked_until = now() + interval '5 minutes'
|
|
WHERE key = $1 AND agent_id = $2
|
|
`,
|
|
[storageKey, agentId]
|
|
);
|
|
await client.query("COMMIT");
|
|
return { status: "claimed" };
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async completeIdempotencyKey(agentId: string, idempotencyKey: string, responseBody: unknown): Promise<void> {
|
|
await this.pool.query(
|
|
`
|
|
UPDATE idempotency_keys
|
|
SET response_body = $3, status = 'completed', locked_until = NULL
|
|
WHERE key = $1 AND agent_id = $2
|
|
`,
|
|
[buildIdempotencyStorageKey(agentId, idempotencyKey), agentId, responseBody]
|
|
);
|
|
}
|
|
|
|
async releaseIdempotencyKey(agentId: string, idempotencyKey: string): Promise<void> {
|
|
await this.pool.query("DELETE FROM idempotency_keys WHERE key = $1 AND agent_id = $2 AND status = 'processing'", [
|
|
buildIdempotencyStorageKey(agentId, idempotencyKey),
|
|
agentId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
function buildIdempotencyStorageKey(agentId: string, idempotencyKey: string): string {
|
|
return `${agentId}:${idempotencyKey}`;
|
|
}
|
|
|
|
function normalizeNullableText(value: string | undefined): string | null {
|
|
const normalized = value?.trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function assertAllowedScopes(scopes: AgentScope[]): void {
|
|
const deniedScope = scopes.find((scope) => !allowedScopeSet.has(scope));
|
|
|
|
if (deniedScope) {
|
|
throw new Error(`Unsupported agent scope: ${deniedScope}`);
|
|
}
|
|
}
|
|
|
|
function mapAgent(row: AgentRow): AgentRecord {
|
|
return {
|
|
id: row.id,
|
|
ownerUserId: row.owner_user_id,
|
|
ownerEmail: row.owner_email,
|
|
displayName: row.display_name,
|
|
avatarUrl: row.avatar_url,
|
|
status: row.status,
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
function mapGrant(row: GrantRow): AgentGrantRecord {
|
|
return {
|
|
id: row.id,
|
|
agentId: row.agent_id,
|
|
workspaceSlug: row.workspace_slug,
|
|
projectId: row.project_id === "" ? null : row.project_id,
|
|
scopes: row.scopes,
|
|
mode: row.mode,
|
|
createdByUserId: row.created_by_user_id,
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
function mapToken(row: TokenRow): AgentTokenRecord {
|
|
return {
|
|
id: row.id,
|
|
agentId: row.agent_id,
|
|
name: row.name,
|
|
status: row.status,
|
|
grantScope: row.grant_scope,
|
|
tokenSuffix: row.token_suffix,
|
|
expiresAt: row.expires_at?.toISOString() ?? null,
|
|
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
createdAt: row.created_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
function mapSetupCode(row: SetupCodeRow): AgentSetupCodeRecord {
|
|
return {
|
|
id: row.id,
|
|
agentId: row.agent_id,
|
|
codeSuffix: row.code_suffix,
|
|
status: row.status,
|
|
tokenName: row.token_name,
|
|
createdByUserId: row.created_by_user_id,
|
|
expiresAt: row.expires_at.toISOString(),
|
|
usedAt: row.used_at?.toISOString() ?? null,
|
|
usedTokenId: row.used_token_id,
|
|
createdAt: row.created_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
function mapTokenGrant(row: TokenGrantRow): AgentGrantRecord {
|
|
return {
|
|
id: row.id,
|
|
agentId: row.agent_id,
|
|
workspaceSlug: row.workspace_slug,
|
|
projectId: row.project_id === "" ? null : row.project_id,
|
|
scopes: row.scopes,
|
|
mode: row.mode,
|
|
createdByUserId: row.created_by_user_id,
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString(),
|
|
};
|
|
}
|