Loading backend/package.json +2 −1 Original line number Diff line number Diff line Loading @@ -21,7 +21,8 @@ "express-session": "^1.18.0", "ioredis": "^5.6.1", "oidc-provider": "^8.8.1", "openid-client": "^5.6.5" "openid-client": "^5.6.5", "string-strip-html": "^13.4.12" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", Loading backend/prisma/migrations/20250608044015_handoff_sessions/migration.sql 0 → 100644 +10 −0 Original line number Diff line number Diff line -- CreateTable CREATE TABLE "HandoffSession" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "HandoffSession_pkey" PRIMARY KEY ("id") ); backend/prisma/schema.prisma +9 −0 Original line number Diff line number Diff line Loading @@ -51,3 +51,12 @@ model FediverseUser { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } model HandoffSession { id String @id @default(uuid()) userId String // eg. https://grants.cafe/users/grant createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt expiresAt DateTime } backend/src/controllers/HandoffSession.ts 0 → 100644 +82 −0 Original line number Diff line number Diff line import { HandoffSession as DBHandoffSession } from "@prisma/client"; import { prisma } from "../lib/prisma.js"; export class HandoffSession { static async create(userId: string): Promise<HandoffSession> { // 15 minutes const expiresAt = new Date(Date.now() + 1000 * 60 * 15); const session = await prisma.handoffSession.create({ data: { userId, expiresAt, }, }); return new HandoffSession(session); } static async getExpired() { const sessions = await prisma.handoffSession.findMany({ where: { expiresAt: { lte: new Date(), }, }, }); return sessions.map((d) => new HandoffSession(d)); } static async get(id: string) { const session = await prisma.handoffSession.findFirst({ where: { id, expiresAt: { gt: new Date() }, }, }); if (!session) return null; return new HandoffSession(session); } private _id: string; private _userId: string; private _createdAt: Date; private _updatedAt: Date; private _expiresAt: Date; private constructor(session: DBHandoffSession) { this._id = session.id; this._userId = session.userId; this._createdAt = session.createdAt; this._updatedAt = session.updatedAt; this._expiresAt = session.expiresAt; } get id() { return this._id; } get userId() { return this._userId; } get createdAt() { return this._createdAt; } get updatedAt() { return this._updatedAt; } get expiresAt() { return this._expiresAt; } getURL() { return new URL(`/handoff/${this._id}`, process.env.OIDC_ISSUER); } destroy() { return prisma.handoffSession.delete({ where: { id: this._id } }); } } backend/src/handoff/activitypub.ts 0 → 100644 +82 −0 Original line number Diff line number Diff line import { Actor, ChatMessage, Create, Mention, Note } from "@fedify/fedify"; import { HandoffSession } from "../controllers/HandoffSession.js"; import { APub } from "../lib/apub/utils.js"; import { USER_IDENTIFIER } from "../lib/apub/federation.js"; import { Temporal } from "@js-temporal/polyfill"; import { stripHtml } from "string-strip-html"; type Message = Note | ChatMessage; /** * Handle automated exchange of tokens from third party apps */ export class HandoffActivityPub { async handle(actor: Actor, object: Message, create: Create) { if (!object.content && !object.contents[0]) return; if (!actor.id) return; const check = this.isObjectValid(object); if (!check.valid) return; const handoff = await HandoffSession.create(actor.id.toString()); const apub = APub.get(); const sender = apub.ctx.getActorUri(USER_IDENTIFIER); if (object instanceof Note) { await apub.sendNote( "handoff-" + handoff.id, actor, new Note({ id: apub.ctx.getObjectUri(Note, { id: "handoff-" + handoff.id }), attribution: sender, to: actor.id, published: Temporal.Instant.from(handoff.createdAt.toISOString()), replyTarget: object, content: handoff.getURL().toString(), tags: [ new Mention({ href: actor.id, name: actor.id!.toString(), }), ], }) ); } if (object instanceof ChatMessage) { await apub.sendChatMessage( "handoff-" + handoff.id, actor, new ChatMessage({ id: apub.ctx.getObjectUri(ChatMessage, { id: "handoff-" + handoff.id, }), attribution: sender, to: actor.id, published: Temporal.Instant.from(handoff.createdAt.toISOString()), replyTarget: object, content: handoff.getURL().toString(), }) ); } } isObjectValid( object: Message ): { valid: false } | { valid: true; software: string } { const TOKEN = process.env.AUTOMATED_EXCHANGE_TOKEN; if (!TOKEN) return { valid: false }; const content = stripHtml( object.content?.toString() || object.contents[0]?.toString() ).result; if (content.indexOf(TOKEN) === -1) return { valid: false }; return { valid: true, software: "unknown", }; } } Loading
backend/package.json +2 −1 Original line number Diff line number Diff line Loading @@ -21,7 +21,8 @@ "express-session": "^1.18.0", "ioredis": "^5.6.1", "oidc-provider": "^8.8.1", "openid-client": "^5.6.5" "openid-client": "^5.6.5", "string-strip-html": "^13.4.12" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", Loading
backend/prisma/migrations/20250608044015_handoff_sessions/migration.sql 0 → 100644 +10 −0 Original line number Diff line number Diff line -- CreateTable CREATE TABLE "HandoffSession" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "HandoffSession_pkey" PRIMARY KEY ("id") );
backend/prisma/schema.prisma +9 −0 Original line number Diff line number Diff line Loading @@ -51,3 +51,12 @@ model FediverseUser { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } model HandoffSession { id String @id @default(uuid()) userId String // eg. https://grants.cafe/users/grant createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt expiresAt DateTime }
backend/src/controllers/HandoffSession.ts 0 → 100644 +82 −0 Original line number Diff line number Diff line import { HandoffSession as DBHandoffSession } from "@prisma/client"; import { prisma } from "../lib/prisma.js"; export class HandoffSession { static async create(userId: string): Promise<HandoffSession> { // 15 minutes const expiresAt = new Date(Date.now() + 1000 * 60 * 15); const session = await prisma.handoffSession.create({ data: { userId, expiresAt, }, }); return new HandoffSession(session); } static async getExpired() { const sessions = await prisma.handoffSession.findMany({ where: { expiresAt: { lte: new Date(), }, }, }); return sessions.map((d) => new HandoffSession(d)); } static async get(id: string) { const session = await prisma.handoffSession.findFirst({ where: { id, expiresAt: { gt: new Date() }, }, }); if (!session) return null; return new HandoffSession(session); } private _id: string; private _userId: string; private _createdAt: Date; private _updatedAt: Date; private _expiresAt: Date; private constructor(session: DBHandoffSession) { this._id = session.id; this._userId = session.userId; this._createdAt = session.createdAt; this._updatedAt = session.updatedAt; this._expiresAt = session.expiresAt; } get id() { return this._id; } get userId() { return this._userId; } get createdAt() { return this._createdAt; } get updatedAt() { return this._updatedAt; } get expiresAt() { return this._expiresAt; } getURL() { return new URL(`/handoff/${this._id}`, process.env.OIDC_ISSUER); } destroy() { return prisma.handoffSession.delete({ where: { id: this._id } }); } }
backend/src/handoff/activitypub.ts 0 → 100644 +82 −0 Original line number Diff line number Diff line import { Actor, ChatMessage, Create, Mention, Note } from "@fedify/fedify"; import { HandoffSession } from "../controllers/HandoffSession.js"; import { APub } from "../lib/apub/utils.js"; import { USER_IDENTIFIER } from "../lib/apub/federation.js"; import { Temporal } from "@js-temporal/polyfill"; import { stripHtml } from "string-strip-html"; type Message = Note | ChatMessage; /** * Handle automated exchange of tokens from third party apps */ export class HandoffActivityPub { async handle(actor: Actor, object: Message, create: Create) { if (!object.content && !object.contents[0]) return; if (!actor.id) return; const check = this.isObjectValid(object); if (!check.valid) return; const handoff = await HandoffSession.create(actor.id.toString()); const apub = APub.get(); const sender = apub.ctx.getActorUri(USER_IDENTIFIER); if (object instanceof Note) { await apub.sendNote( "handoff-" + handoff.id, actor, new Note({ id: apub.ctx.getObjectUri(Note, { id: "handoff-" + handoff.id }), attribution: sender, to: actor.id, published: Temporal.Instant.from(handoff.createdAt.toISOString()), replyTarget: object, content: handoff.getURL().toString(), tags: [ new Mention({ href: actor.id, name: actor.id!.toString(), }), ], }) ); } if (object instanceof ChatMessage) { await apub.sendChatMessage( "handoff-" + handoff.id, actor, new ChatMessage({ id: apub.ctx.getObjectUri(ChatMessage, { id: "handoff-" + handoff.id, }), attribution: sender, to: actor.id, published: Temporal.Instant.from(handoff.createdAt.toISOString()), replyTarget: object, content: handoff.getURL().toString(), }) ); } } isObjectValid( object: Message ): { valid: false } | { valid: true; software: string } { const TOKEN = process.env.AUTOMATED_EXCHANGE_TOKEN; if (!TOKEN) return { valid: false }; const content = stripHtml( object.content?.toString() || object.contents[0]?.toString() ).result; if (content.indexOf(TOKEN) === -1) return { valid: false }; return { valid: true, software: "unknown", }; } }