diff --git a/schemas/secure-chat-protocol.prs b/schemas/secure-chat-protocol.prs new file mode 100644 index 0000000..4d01ef3 --- /dev/null +++ b/schemas/secure-chat-protocol.prs @@ -0,0 +1,14 @@ +version 1 . +pointer Actor.Ref . + +UserId = int . + +Join = . + +NickClaim = . + +UserInfo = . + +Says = . + +NickConflict = . diff --git a/src/actor.ts b/src/actor.ts index e4e30ae..a010bfe 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -143,8 +143,8 @@ export class Turn { } } - replace(ref: Ref, h: Handle | undefined, assertion: Assertion): Handle { - const newHandle = this.assert(ref, assertion); + replace(ref: Ref, h: Handle | undefined, assertion: Assertion | undefined): Handle | undefined { + const newHandle = assertion === void 0 ? void 0 : this.assert(ref, assertion); this.retract(h); return newHandle; } diff --git a/src/dataspace.ts b/src/dataspace.ts index e141cd7..edf9238 100644 --- a/src/dataspace.ts +++ b/src/dataspace.ts @@ -1,8 +1,8 @@ -import { Assertion, Entity, Handle, Ref, Turn } from 'actor'; +import { Assertion, Entity, Handle, LocalAction, Ref, Turn } from 'actor'; import { Dictionary, IdentityMap, is, Record, Tuple } from '@preserves/core'; import { Bag, ChangeDescription } from './bag'; -import { toObserve } from './gen/dataspace'; +import { fromObserve, toObserve, Observe } from './gen/dataspace'; export * from './gen/dataspace'; // Q. Why keep "Observe"? Why not do the clever trick of asserting the @@ -86,3 +86,21 @@ export class Dataspace implements Partial { this.subscriptions.get(rec.label)?.forEach((_seen, peer) => turn.message(peer, rec)); } } + +export function during(f: (t: Turn, a: Assertion) => (LocalAction | null)): Partial { + const assertionMap = new Map(); + return { + assert(t: Turn, a: Assertion, h: Handle): void { + const g = f(t, a); + if (g !== null) assertionMap.set(h, g); + }, + retract(t: Turn, h: Handle): void { + assertionMap.get(h)?.(t); + assertionMap.delete(h); + }, + }; +} + +export function observe(t: Turn, ds: Ref, label: symbol, e: Partial): Handle { + return t.assert(ds, fromObserve(Observe({ label, observer: t.ref(e) }))); +} diff --git a/src/gen/secure-chat-protocol.ts b/src/gen/secure-chat-protocol.ts new file mode 100644 index 0000000..cb59a8e --- /dev/null +++ b/src/gen/secure-chat-protocol.ts @@ -0,0 +1,182 @@ +import * as _ from "@preserves/core"; +import * as _i_Actor from "../actor"; + +export const $claimNick = Symbol.for("claimNick"); +export const $joinedUser = Symbol.for("joinedUser"); +export const $nickConflict = Symbol.for("nickConflict"); +export const $says = Symbol.for("says"); +export const $user = Symbol.for("user"); + +export type _ptr = _i_Actor.Ref; + +export type _val = _.Value<_ptr>; + +export type UserId = number; + +export type Join = {"uid": UserId, "handle": _ptr}; + +export type NickClaim = {"uid": UserId, "name": string, "k": _ptr}; + +export type UserInfo = {"uid": UserId, "name": string}; + +export type Says = {"who": UserId, "what": string}; + +export type NickConflict = null; + + +export const _toPtr = (v: _val) => {let result: undefined | _ptr; result = _i_Actor.toRef(v); return result;}; + +export function UserId(value: number): UserId {return value;} + +export function Join({uid, handle}: {uid: UserId, handle: _ptr}): Join {return {"uid": uid, "handle": handle};} + +export function NickClaim({uid, name, k}: {uid: UserId, name: string, k: _ptr}): NickClaim {return {"uid": uid, "name": name, "k": k};} + +export function UserInfo({uid, name}: {uid: UserId, name: string}): UserInfo {return {"uid": uid, "name": name};} + +export function Says({who, what}: {who: UserId, what: string}): Says {return {"who": who, "what": what};} + +export function NickConflict(): NickConflict {return null;} + +export function asUserId(v: _val): UserId { + let result = toUserId(v); + if (result === void 0) throw new TypeError(`Invalid UserId: ${_.stringify(v)}`); + return result; +} + +export function toUserId(v: _val): undefined | UserId { + let _tmp0: (number) | undefined; + let result: undefined | UserId; + _tmp0 = typeof v === 'number' ? v : void 0; + if (_tmp0 !== void 0) {result = _tmp0;}; + return result; +} + +export function fromUserId(_v: UserId): _val {return _v;} + +export function asJoin(v: _val): Join { + let result = toJoin(v); + if (result === void 0) throw new TypeError(`Invalid Join: ${_.stringify(v)}`); + return result; +} + +export function toJoin(v: _val): undefined | Join { + let result: undefined | Join; + if (_.Record.isRecord<_val, _.Tuple<_val>, _ptr>(v)) { + let _tmp0: (null) | undefined; + _tmp0 = _.is(v.label, $joinedUser) ? null : void 0; + if (_tmp0 !== void 0) { + let _tmp1: (UserId) | undefined; + _tmp1 = toUserId(v[0]); + if (_tmp1 !== void 0) { + let _tmp2: (_ptr) | undefined; + _tmp2 = _toPtr(v[1]); + if (_tmp2 !== void 0) {result = {"uid": _tmp1, "handle": _tmp2};}; + }; + }; + }; + return result; +} + +export function fromJoin(_v: Join): _val {return _.Record($joinedUser, [fromUserId(_v["uid"]), _v["handle"]]);} + +export function asNickClaim(v: _val): NickClaim { + let result = toNickClaim(v); + if (result === void 0) throw new TypeError(`Invalid NickClaim: ${_.stringify(v)}`); + return result; +} + +export function toNickClaim(v: _val): undefined | NickClaim { + let result: undefined | NickClaim; + if (_.Record.isRecord<_val, _.Tuple<_val>, _ptr>(v)) { + let _tmp0: (null) | undefined; + _tmp0 = _.is(v.label, $claimNick) ? null : void 0; + if (_tmp0 !== void 0) { + let _tmp1: (UserId) | undefined; + _tmp1 = toUserId(v[0]); + if (_tmp1 !== void 0) { + let _tmp2: (string) | undefined; + _tmp2 = typeof v[1] === 'string' ? v[1] : void 0; + if (_tmp2 !== void 0) { + let _tmp3: (_ptr) | undefined; + _tmp3 = _toPtr(v[2]); + if (_tmp3 !== void 0) {result = {"uid": _tmp1, "name": _tmp2, "k": _tmp3};}; + }; + }; + }; + }; + return result; +} + +export function fromNickClaim(_v: NickClaim): _val {return _.Record($claimNick, [fromUserId(_v["uid"]), _v["name"], _v["k"]]);} + +export function asUserInfo(v: _val): UserInfo { + let result = toUserInfo(v); + if (result === void 0) throw new TypeError(`Invalid UserInfo: ${_.stringify(v)}`); + return result; +} + +export function toUserInfo(v: _val): undefined | UserInfo { + let result: undefined | UserInfo; + if (_.Record.isRecord<_val, _.Tuple<_val>, _ptr>(v)) { + let _tmp0: (null) | undefined; + _tmp0 = _.is(v.label, $user) ? null : void 0; + if (_tmp0 !== void 0) { + let _tmp1: (UserId) | undefined; + _tmp1 = toUserId(v[0]); + if (_tmp1 !== void 0) { + let _tmp2: (string) | undefined; + _tmp2 = typeof v[1] === 'string' ? v[1] : void 0; + if (_tmp2 !== void 0) {result = {"uid": _tmp1, "name": _tmp2};}; + }; + }; + }; + return result; +} + +export function fromUserInfo(_v: UserInfo): _val {return _.Record($user, [fromUserId(_v["uid"]), _v["name"]]);} + +export function asSays(v: _val): Says { + let result = toSays(v); + if (result === void 0) throw new TypeError(`Invalid Says: ${_.stringify(v)}`); + return result; +} + +export function toSays(v: _val): undefined | Says { + let result: undefined | Says; + if (_.Record.isRecord<_val, _.Tuple<_val>, _ptr>(v)) { + let _tmp0: (null) | undefined; + _tmp0 = _.is(v.label, $says) ? null : void 0; + if (_tmp0 !== void 0) { + let _tmp1: (UserId) | undefined; + _tmp1 = toUserId(v[0]); + if (_tmp1 !== void 0) { + let _tmp2: (string) | undefined; + _tmp2 = typeof v[1] === 'string' ? v[1] : void 0; + if (_tmp2 !== void 0) {result = {"who": _tmp1, "what": _tmp2};}; + }; + }; + }; + return result; +} + +export function fromSays(_v: Says): _val {return _.Record($says, [fromUserId(_v["who"]), _v["what"]]);} + +export function asNickConflict(v: _val): NickConflict { + let result = toNickConflict(v); + if (result === void 0) throw new TypeError(`Invalid NickConflict: ${_.stringify(v)}`); + return result; +} + +export function toNickConflict(v: _val): undefined | NickConflict { + let result: undefined | NickConflict; + if (_.Record.isRecord<_val, _.Tuple<_val>, _ptr>(v)) { + let _tmp0: (null) | undefined; + _tmp0 = _.is(v.label, $nickConflict) ? null : void 0; + if (_tmp0 !== void 0) {result = null;}; + }; + return result; +} + +export function fromNickConflict(_v: NickConflict): _val {return _.Record($nickConflict, []);} + diff --git a/src/secure-chat-client.ts b/src/secure-chat-client.ts new file mode 100644 index 0000000..ba57e03 --- /dev/null +++ b/src/secure-chat-client.ts @@ -0,0 +1,66 @@ +import { $joinedUser, $says, $user, asJoin, asSays, asUserInfo, fromNickClaim, fromSays, NickClaim, Says, UserId } from "./gen/secure-chat-protocol.js"; +import { during, observe } from "./dataspace.js"; +import { Assertion, Ref, Turn } from "./actor.js"; +import readline from 'readline'; + +export default function (t: Turn, ds: Ref) { + observe(t, ds, $joinedUser, { + assert(t, j0) { + const j = asJoin(j0); + runSession(t, j.uid, j.handle); + } + }); +} + +function runSession(t: Turn, uid: UserId, session: Ref) { + let username: string | undefined; + + function updateUsername(t: Turn, name: string) { + const q = t.assert(session, fromNickClaim(NickClaim({ uid, name, k: t.ref({ + message(t, reply0) { + if (reply0 === true) { + username = name; + console.log(`Nick changed to ${username}`); + } else { + console.log(`Nick conflict: name is still ${username ?? ''}`); + } + t.retract(q); + } + })}))); + } + updateUsername(t, 'user' + process.pid); + + const users = new Map(); + observe(t, session, $user, during((_t, ui0) => { + const ui = asUserInfo(ui0); + const oldName = users.get(ui.uid); + console.log(oldName === void 0 + ? `${ui.name} arrived` + : `${oldName} changed name to ${ui.name}`); + users.set(ui.uid, ui.name); + return (_t) => { + if (users.get(ui.uid) === ui.name) { + console.log(`${ui.name} departed`); + } + }; + })); + + observe(t, session, $says, { + message(_t: Turn, u0: Assertion): void { + const u = asSays(u0); + console.log(`${users.get(u.who) ?? ``}: ${u.what}`); + }, + }); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + rl.on('line', (line: string) => t.freshen(t => { + if (line.toLowerCase().startsWith('/nick ')) { + updateUsername(t, line.slice(5).trimLeft()); + } else { + t.message(session, fromSays(Says({ who: uid, what: line }))); + } + })); + + rl.on('close', () => t.freshen(t => t.quit())); +} diff --git a/src/secure-chat-moderator.ts b/src/secure-chat-moderator.ts new file mode 100644 index 0000000..cd76dd0 --- /dev/null +++ b/src/secure-chat-moderator.ts @@ -0,0 +1,42 @@ +import { $claimNick, $joinedUser, asNickClaim, fromJoin, fromNickConflict, fromUserInfo, Join, NickConflict, UserId, UserInfo } from "./gen/secure-chat-protocol.js"; +import { Assertion, Handle, Ref, Turn } from "./actor.js"; +import { observe, during, $Observe, asObserve } from "./dataspace.js"; + +export default function (t: Turn, ds: Ref) { + let nextUserId: UserId = 0; + + const nicks = new Map(); + const users = new Map(); + + observe(t, ds, $claimNick, { + assert(t: Turn, c0: Assertion): void { + const c = asNickClaim(c0); + if (nicks.has(c.name)) { + t.message(c.k, fromNickConflict(NickConflict())); + } else { + t.message(c.k, true); + let u = users.get(c.uid); + if (!u) { + u = { infoHandle: void 0, nick: void 0 }; + users.set(c.uid, u); + } + if (u.nick !== void 0) nicks.delete(u.nick); + u.infoHandle = t.replace(ds, u.infoHandle, fromUserInfo(UserInfo(c))); + u.nick = c.name; + nicks.set(c.name, c.uid); + } + } + }); + + observe(t, ds, $Observe, during((t, o0) => { + const o = asObserve(o0); + if (o.label !== $joinedUser) return null; + + const uid: UserId = nextUserId++; + const h = t.assert(o.observer, fromJoin(Join({ + uid, + handle: ds, + }))); + return t => t.retract(h); + })); +} diff --git a/src/simple-chat.ts b/src/simple-chat.ts index d64afd3..418e5dc 100644 --- a/src/simple-chat.ts +++ b/src/simple-chat.ts @@ -1,6 +1,6 @@ import { $Present, $Says, asPresent, asSays, fromPresent, fromSays, Present, Says } from "./gen/simple-chat-protocol.js"; -import { fromObserve, Observe } from "./gen/dataspace.js"; -import { Assertion, Handle, LocalAction, Ref, Turn } from "./actor.js"; +import { during, observe } from "./dataspace.js"; +import { Assertion, Handle, Ref, Turn } from "./actor.js"; import readline from 'readline'; export default function (t: Turn, ds: Ref) { @@ -13,31 +13,18 @@ export default function (t: Turn, ds: Ref) { } updateUsername(t, 'user' + process.pid); - const usermap = new Map(); - t.assert(ds, fromObserve(Observe({ - label: $Present, - observer: t.ref({ - assert(_t: Turn, e0: Assertion, h: Handle): void { - const e = asPresent(e0); - console.log(`${e.username} arrived`); - usermap.set(h, (_t: Turn) => console.log(`${e.username} departed`)); - }, - retract(t: Turn, h: Handle): void { - usermap.get(h)?.(t); - usermap.delete(h); - }, - }), - }))); + observe(t, ds, $Present, during((_t, e0) => { + const e = asPresent(e0); + console.log(`${e.username} arrived`); + return (_t) => console.log(`${e.username} departed`); + })); - t.assert(ds, fromObserve(Observe({ - label: $Says, - observer: t.ref({ - message(_t: Turn, u0: Assertion): void { - const u = asSays(u0); - console.log(`${u.who}: ${u.what}`); - }, - }), - }))); + observe(t, ds, $Says, { + message(_t: Turn, u0: Assertion): void { + const u = asSays(u0); + console.log(`${u.who}: ${u.what}`); + }, + }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });