~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-socialweb-computer-requester

socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons

0
0
main
223 lines · 10.1 KB Raw History
import { Cubit } from 'atlibs/cubit/index.js'
import { enabledFeatures, grantedSessions, requestScope } from 'atlibs/oauth/index.js'

// Lines atirc makes permanent vibescrobble records of: a leading emoji, as atirc's
// cli/src/server/scrobble.rs leading_emoji decides it (U+1F000-1FAFF, BMP Emoji_Presentation,
// VS16, keycaps).
const SCROBBLE = /^(?:[\u{1F000}-\u{1FAFF}]|\p{Emoji_Presentation}|\S️|[0-9#*]️?⃣)/u;
const QUESTION = /^\[(ask|answer|decided) ([0-9a-f-]{36})\] ?([\s\S]*)$/;
// An agent asks the page to request a VM: "vm-request {json requester request input}".
// The page answers "vm-request <seq>: ...".
const VM_REQUEST = /^vm-request (\{[\s\S]*\})\s*$/;
const VM_REPLY = /^vm-request (\d+):/;
const REQUEST_KEYS = ["role", "user_data", "bidders", "policies"];

// What a channel line is: scrobble | ask | answer | decided | vm-request | vm-reply | chat.
export function classify({ text }) {
	if (SCROBBLE.test(text)) return { kind: "scrobble" };
	const q = text.match(QUESTION);
	if (q) return { kind: q[1], id: q[2], body: q[3] };
	const reply = text.match(VM_REPLY);
	if (reply) return { kind: "vm-reply", seq: Number(reply[1]) };
	const request = text.match(VM_REQUEST);
	if (request) {
		try {
			const input = JSON.parse(request[1]);
			return { kind: "vm-request", input: Object.fromEntries(REQUEST_KEYS.filter((k) => k in input).map((k) => [k, input[k]])) };
		} catch {}
	}
	return { kind: "chat" };
}

// atirc's handle channel: "#" + handle, dots to underscores, lowercased.
export const handleChannel = (handle) => `#${String(handle).replaceAll(".", "_").toLowerCase()}`;

// Whether a question line could have come from atirc for the channel's account. Anyone may
// post "[answer <id>] ..." text, so:
//   ask      from the channel owner's account (its DID: the owner or one of its agents)
//   answer   from the channel owner itself (the identity whose handle channel this is)
//   decided  from the agent that asked, or the owner
// Anything else is shown as a plain line flagged unverified. chat.atirc.questions stays the
// authoritative record.
function verified(line, id, { owner, ownerDid, questions }) {
	if (line.kind === "ask") return Boolean(ownerDid) && line.did === ownerDid && !questions.has(line.id);
	if (line.kind === "answer") return id === owner && questions.has(line.id);
	return questions.has(line.id) && (id === questions.get(line.id).askedBy || id === owner);
}

// Agents (one per identity, i.e. subhandle), owner questions as the channel tells them, and
// VM requests from agents of `did` (the signed-in account) with whether one was answered.
//   did      the signed-in account; handle  its handle; channel  the channel read
export function channelView(list, { did, handle, channel }) {
	const agents = new Map();
	const questions = new Map();
	const requests = [];
	const replied = new Set();
	const owner = list.find((m) => m.identity && handleChannel(m.identity) === channel?.toLowerCase())?.identity
		?? (handle && handleChannel(handle) === channel?.toLowerCase() ? handle : null);
	const ownerDid = list.find((m) => m.identity === owner)?.did ?? (owner === handle ? did : null);
	const lines = list.map((m) => {
		const line = { ...m, ...classify(m) };
		const id = line.identity ?? line.nick;
		const agent = agents.get(id) ?? { identity: id, nick: line.nick, did: line.did, lines: 0, scrobbles: 0, last: null };
		agents.set(id, { ...agent, lines: agent.lines + 1, scrobbles: agent.scrobbles + (line.kind === "scrobble"), last: line.ts });
		if (["ask", "answer", "decided"].includes(line.kind)) {
			if (!verified(line, id, { owner, ownerDid, questions })) return { ...line, kind: "chat", unverified: true };
			if (line.kind === "ask") questions.set(line.id, { id: line.id, question: line.body, askedBy: id, status: "pending" });
			else questions.set(line.id, { ...questions.get(line.id), status: line.kind === "answer" ? "answered" : "decided", outcome: line.body, by: id });
		}
		if (line.kind === "vm-reply" && line.did === did) replied.add(line.seq);
		if (line.kind === "vm-request" && did && line.did === did) requests.push({ seq: line.seq, identity: id, input: line.input });
		return line;
	});
	return {
		owner,
		lines,
		agents: [...agents.values()].sort((a, b) => String(b.last).localeCompare(String(a.last))),
		questions: [...questions.values()].reverse(),
		requests: requests.map((r) => ({ ...r, replied: replied.has(r.seq) })).reverse(),
	};
}

// One BrowserOAuthClient session shared by the module cubits. client(name) is the
// { init, signIn, revoke } a module cubit expects of a BrowserOAuthClient: init() hands it
// the session only when the session's granted scope covers that module's scope.
export function sharedSessions(client, policy) {
	let ready;
	const init = () => (ready ??= (async () => {
		const result = await (await client).init();
		if (!result) return { session: null, granted: [] };
		const { scope } = await result.session.getTokenInfo(false);
		return { session: result.session, granted: grantedSessions(policy, scope) };
	})());
	const signIn = async (identifier, names) => (await client).signIn(identifier, { scope: requestScope(policy, names), signal: new AbortController().signal });
	return {
		init,
		signIn,
		async revoke(did) {
			await (await client).revoke(did).catch(() => undefined);
			ready = Promise.resolve({ session: null, granted: [] });
		},
		client: (name) => ({
			init: async () => {
				const { session, granted } = await init();
				return granted.includes(name) ? { session } : undefined;
			},
			signIn: async (identifier) => signIn(identifier, [...(await init()).granted, name]),
			// The page revokes the one session (SocialwebCubit.logout); a module alone cannot.
			revoke: async () => undefined,
		}),
	};
}

// session: "loading" | "signed-out" | "signed-in" | "error"; granted: module session names;
// features: what the granted sessions unlock (agents = requester + atirc).
// approvals: {seq: {status: "running" | "done" | "error", result?, error?}} of agent VM requests.
const INITIAL = {
	session: "loading",
	did: null,
	error: null,
	login: { busy: false, error: null },
	granted: [],
	features: [],
	allowAgentRequests: false,
	approvals: {},
	announce: { status: "idle", error: null },
};

// The page: the shared session, which module sessions it holds, and the agent features that
// need both. The module cubits (AtircCubit, WebrtcCubit) keep their own state and are driven
// from here for the agent features.
//   sessions  sharedSessions(client, policy)
//   policy    { sessions, features }
//   atirc     AtircCubit built with sessions.client("atirc")
//   compute   WebrtcCubit built with sessions.client("requester")
export class SocialwebCubit extends Cubit {
	#sessions;
	#policy;
	#atirc;
	#compute;

	constructor({ sessions, policy, atirc, compute }) {
		super(INITIAL);
		this.#sessions = sessions;
		this.#policy = policy;
		this.#atirc = atirc;
		this.#compute = compute;
	}

	async init() {
		try {
			const { session, granted } = await this.#sessions.init();
			this.emit(session ? { session: "signed-in", did: session.did, granted, features: enabledFeatures(this.#policy, granted) } : { session: "signed-out" });
		} catch (error) {
			return this.emit({ session: "error", error: `An error occurred: ${error.message}` });
		}
		// Each module cubit gets the session only if it was granted; signed out, they say so.
		await Promise.all([this.#atirc.init(), this.#compute.init()]);
	}

	// One consent screen for the identity plus the chosen module sessions.
	async login(identifier, names) {
		this.emit({ login: { busy: true, error: null } });
		try {
			await this.#sessions.signIn(identifier, names);
		} catch (error) {
			this.emit({ login: { busy: false, error: `Login error: ${error.message}` } });
		}
	}

	// Grow or shrink the session to these module sessions (a new consent, then a redirect back).
	setSessions(names) {
		return this.#sessions.signIn(this.state.did, names);
	}

	async logout() {
		await this.#sessions.revoke(this.state.did);
		await Promise.all([this.#atirc, this.#compute].filter((c) => c.state.session === "signed-in").map((c) => c.logout()));
		this.emit({ ...INITIAL, session: "signed-out" });
	}

	#require(feature) {
		if (!this.state.features.includes(feature)) throw new Error(`${feature} needs the ${this.#policy.features[feature].requires.join(" and ")} sessions`);
	}

	// Agent feature: post the VM this page is connected to on the atirc channel.
	async announce() {
		this.emit({ announce: { status: "busy", error: null } });
		try {
			this.#require("agents");
			const { receipt, tag, phase } = this.#compute.state;
			if (!receipt) throw new Error("find a guest first: no receipt");
			await this.#atirc.post(`vm: ${receipt.uri} tag ${tag} ${phase} (socialweb-computer-ui)`);
			const { post } = this.#atirc.state;
			this.emit({ announce: post.status === "error" ? { status: "error", error: post.error } : { status: "done", error: null } });
		} catch (error) {
			this.emit({ announce: { status: "error", error: error.message } });
		}
	}

	allowAgentRequests(allow) {
		this.emit({ allowAgentRequests: Boolean(allow) });
	}

	// Agent feature: run an agent's "vm-request {...}" line as the requester `request` entry,
	// once the owner approves it here, and answer it on the channel with the RFP.
	async approve(seq) {
		const set = (approval) => this.emit({ approvals: { ...this.state.approvals, [seq]: approval } });
		set({ status: "running" });
		try {
			this.#require("agents");
			if (!this.state.allowAgentRequests) throw new Error("agent VM requests are not allowed on this page");
			const { messages, handle, channel } = this.#atirc.state;
			const request = channelView(messages.list, { did: this.state.did, handle, channel }).requests.find((r) => r.seq === seq);
			if (!request) throw new Error(`no vm-request ${seq} from your agents on this channel`);
			if (request.replied) throw new Error(`vm-request ${seq} was already answered`);
			const result = await this.#compute.run("request", request.input);
			set({ status: "done", result });
			await this.#atirc.post(`vm-request ${seq}: rfp ${result.rfp?.uri} vm ${result.vm?.uri} submitted to ${result.submitted?.length ?? 0} bidder(s)`);
		} catch (error) {
			set({ status: "error", error: error.message });
		}
	}
}