feat: add private profile media and realtime contracts
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
export {
|
||||
ACCENTS,
|
||||
BODY_SHAPES,
|
||||
HAIR_COLORS,
|
||||
LOCAL_PROFILE_VERSION,
|
||||
OUTFITS,
|
||||
SKIN_TONES,
|
||||
actorKindForPresence,
|
||||
createDefaultLocalProfile,
|
||||
decodeLocalProfile,
|
||||
encodeLocalProfile,
|
||||
isHumanoidAppearance,
|
||||
isLocalProfile,
|
||||
migrateLocalProfileV1,
|
||||
resolveHumanoidAppearance,
|
||||
type AccentChoice,
|
||||
type BodyShapeChoice,
|
||||
type HairColorChoice,
|
||||
type HumanoidAppearanceChoices,
|
||||
type LocalProfile,
|
||||
type LocalProfileV1,
|
||||
type LocalProfileV2,
|
||||
type OutfitChoice,
|
||||
type PresenceActorKind,
|
||||
type PresenceContext,
|
||||
type ProfileDecodeResult,
|
||||
type ResolvedHumanoidAppearance,
|
||||
type SkinToneChoice,
|
||||
} from "./model.ts";
|
||||
|
||||
export {
|
||||
clearLocalProfile,
|
||||
loadLocalProfile,
|
||||
saveLocalProfile,
|
||||
type ProfileClearResult,
|
||||
type ProfileLoadResult,
|
||||
type ProfileSaveResult,
|
||||
type ProfileStorageAdapter,
|
||||
} from "./persistence.ts";
|
||||
|
||||
export {
|
||||
createWebcamFaceConsent,
|
||||
initialWebcamFaceConsent,
|
||||
transitionWebcamFaceConsent,
|
||||
type WebcamFaceConsentController,
|
||||
type WebcamFaceConsentEvent,
|
||||
type WebcamFaceConsentRejection,
|
||||
type WebcamFaceConsentState,
|
||||
type WebcamFaceConsentStatus,
|
||||
type WebcamFaceConsentTransition,
|
||||
} from "./webcamConsent.ts";
|
||||
@@ -0,0 +1,260 @@
|
||||
/** Versioned, JSON-only local profile data for an authenticated Tera member. */
|
||||
|
||||
export const LOCAL_PROFILE_VERSION = 2 as const;
|
||||
|
||||
export const SKIN_TONES = ["espresso", "umber", "sienna", "sand", "peach"] as const;
|
||||
export const OUTFITS = ["ink", "navy", "slate", "sage", "clay"] as const;
|
||||
export const ACCENTS = ["aqua", "amber", "coral", "violet", "lime"] as const;
|
||||
export const HAIR_COLORS = ["black", "espresso", "auburn", "silver", "platinum"] as const;
|
||||
export const BODY_SHAPES = ["slim", "average", "broad"] as const;
|
||||
|
||||
export type SkinToneChoice = (typeof SKIN_TONES)[number];
|
||||
export type OutfitChoice = (typeof OUTFITS)[number];
|
||||
export type AccentChoice = (typeof ACCENTS)[number];
|
||||
export type HairColorChoice = (typeof HAIR_COLORS)[number];
|
||||
export type BodyShapeChoice = (typeof BODY_SHAPES)[number];
|
||||
|
||||
export interface HumanoidAppearanceChoices {
|
||||
skinTone: SkinToneChoice;
|
||||
outfit: OutfitChoice;
|
||||
accent: AccentChoice;
|
||||
hair: HairColorChoice;
|
||||
bodyShape: BodyShapeChoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* This intentionally contains no identity id, auth credential, webcam state,
|
||||
* face URL, image, device id, or arbitrary metadata bucket.
|
||||
*/
|
||||
export interface LocalProfileV2 {
|
||||
version: typeof LOCAL_PROFILE_VERSION;
|
||||
displayName: string;
|
||||
appearance: HumanoidAppearanceChoices;
|
||||
}
|
||||
|
||||
/** The only supported legacy shape: V1 kept appearance choices flat. */
|
||||
export interface LocalProfileV1 {
|
||||
version: 1;
|
||||
displayName: string;
|
||||
skinTone: SkinToneChoice;
|
||||
outfit: OutfitChoice;
|
||||
accent: AccentChoice;
|
||||
hair: HairColorChoice;
|
||||
bodyShape: BodyShapeChoice;
|
||||
}
|
||||
|
||||
export type LocalProfile = LocalProfileV2;
|
||||
|
||||
export type ProfileDecodeResult =
|
||||
| { ok: true; profile: LocalProfile; migrated: false }
|
||||
| { ok: true; profile: LocalProfile; migrated: true; fromVersion: 1 }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface ResolvedHumanoidAppearance {
|
||||
skinTone: string;
|
||||
outfitColor: string;
|
||||
accentColor: string;
|
||||
hairColor: string;
|
||||
bodyShape: BodyShapeChoice;
|
||||
}
|
||||
|
||||
export type PresenceContext = "outdoors" | "office";
|
||||
export type PresenceActorKind = "humanoid" | "dog" | "crow";
|
||||
|
||||
const SKIN_COLORS: Readonly<Record<SkinToneChoice, string>> = Object.freeze({
|
||||
espresso: "#4b2d25",
|
||||
umber: "#704534",
|
||||
sienna: "#9a6248",
|
||||
sand: "#c58d68",
|
||||
peach: "#e6b28e",
|
||||
});
|
||||
|
||||
const OUTFIT_COLORS: Readonly<Record<OutfitChoice, string>> = Object.freeze({
|
||||
ink: "#18222d",
|
||||
navy: "#1d3b58",
|
||||
slate: "#53616d",
|
||||
sage: "#566c5d",
|
||||
clay: "#80594a",
|
||||
});
|
||||
|
||||
const ACCENT_COLORS: Readonly<Record<AccentChoice, string>> = Object.freeze({
|
||||
aqua: "#55b8c8",
|
||||
amber: "#d99b39",
|
||||
coral: "#db6d62",
|
||||
violet: "#826faf",
|
||||
lime: "#86a94a",
|
||||
});
|
||||
|
||||
const HAIR_VALUES: Readonly<Record<HairColorChoice, string>> = Object.freeze({
|
||||
black: "#171719",
|
||||
espresso: "#2d211d",
|
||||
auburn: "#65372b",
|
||||
silver: "#8b9298",
|
||||
platinum: "#d0c5ac",
|
||||
});
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const wanted = [...expected].sort();
|
||||
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, choices: readonly T[]): value is T {
|
||||
return typeof value === "string" && (choices as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function validDisplayName(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value === value.trim() &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 64 &&
|
||||
!/[\u0000-\u001f\u007f]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHumanoidAppearance(value: unknown): value is HumanoidAppearanceChoices {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
exactKeys(value, ["skinTone", "outfit", "accent", "hair", "bodyShape"]) &&
|
||||
oneOf(value.skinTone, SKIN_TONES) &&
|
||||
oneOf(value.outfit, OUTFITS) &&
|
||||
oneOf(value.accent, ACCENTS) &&
|
||||
oneOf(value.hair, HAIR_COLORS) &&
|
||||
oneOf(value.bodyShape, BODY_SHAPES)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLocalProfile(value: unknown): value is LocalProfile {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
exactKeys(value, ["version", "displayName", "appearance"]) &&
|
||||
value.version === LOCAL_PROFILE_VERSION &&
|
||||
validDisplayName(value.displayName) &&
|
||||
isHumanoidAppearance(value.appearance)
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalProfileV1(value: unknown): value is LocalProfileV1 {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
exactKeys(value, ["version", "displayName", "skinTone", "outfit", "accent", "hair", "bodyShape"]) &&
|
||||
value.version === 1 &&
|
||||
validDisplayName(value.displayName) &&
|
||||
oneOf(value.skinTone, SKIN_TONES) &&
|
||||
oneOf(value.outfit, OUTFITS) &&
|
||||
oneOf(value.accent, ACCENTS) &&
|
||||
oneOf(value.hair, HAIR_COLORS) &&
|
||||
oneOf(value.bodyShape, BODY_SHAPES)
|
||||
);
|
||||
}
|
||||
|
||||
function clone(profile: LocalProfile): LocalProfile {
|
||||
return { ...profile, appearance: { ...profile.appearance } };
|
||||
}
|
||||
|
||||
export function migrateLocalProfileV1(profile: LocalProfileV1): LocalProfile {
|
||||
if (!isLocalProfileV1(profile)) throw new RangeError("profile: invalid V1 profile");
|
||||
return {
|
||||
version: LOCAL_PROFILE_VERSION,
|
||||
displayName: profile.displayName,
|
||||
appearance: {
|
||||
skinTone: profile.skinTone,
|
||||
outfit: profile.outfit,
|
||||
accent: profile.accent,
|
||||
hair: profile.hair,
|
||||
bodyShape: profile.bodyShape,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Strictly decode current data or the one documented legacy version. */
|
||||
export function decodeLocalProfile(payload: string | unknown): ProfileDecodeResult {
|
||||
let value: unknown = payload;
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
value = JSON.parse(payload) as unknown;
|
||||
} catch {
|
||||
return { ok: false, error: "profile: data is not valid JSON" };
|
||||
}
|
||||
}
|
||||
if (!isRecord(value) || !Number.isInteger(value.version)) {
|
||||
return { ok: false, error: "profile: missing version" };
|
||||
}
|
||||
if (value.version === LOCAL_PROFILE_VERSION) {
|
||||
if (!isLocalProfile(value)) return { ok: false, error: "profile: invalid V2 profile" };
|
||||
return { ok: true, profile: clone(value), migrated: false };
|
||||
}
|
||||
if (value.version === 1) {
|
||||
if (!isLocalProfileV1(value)) return { ok: false, error: "profile: invalid V1 profile" };
|
||||
return { ok: true, profile: migrateLocalProfileV1(value), migrated: true, fromVersion: 1 };
|
||||
}
|
||||
return { ok: false, error: "profile: unsupported version" };
|
||||
}
|
||||
|
||||
export function encodeLocalProfile(profile: LocalProfile): string {
|
||||
if (!isLocalProfile(profile)) throw new RangeError("profile: cannot encode invalid profile");
|
||||
return JSON.stringify(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-id defaults without retaining the id or its hash. Salted hashes
|
||||
* keep each appearance axis independent and reveal nothing in the output that
|
||||
* was not already one of the public palette choices.
|
||||
*/
|
||||
export function createDefaultLocalProfile(identityId: string, displayName: string): LocalProfile {
|
||||
if (typeof identityId !== "string" || identityId.length === 0) {
|
||||
throw new RangeError("profile: identity id is required for deterministic defaults");
|
||||
}
|
||||
const name = displayName.trim();
|
||||
if (!validDisplayName(name)) throw new RangeError("profile: display name is invalid");
|
||||
function choose<T>(items: readonly T[], salt: string): T {
|
||||
const item = items[stableHash(`${salt}\u0000${identityId}`) % items.length];
|
||||
if (item === undefined) throw new Error("profile: empty choice catalogue");
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
version: LOCAL_PROFILE_VERSION,
|
||||
displayName: name,
|
||||
appearance: {
|
||||
skinTone: choose(SKIN_TONES, "skin"),
|
||||
outfit: choose(OUTFITS, "outfit"),
|
||||
accent: choose(ACCENTS, "accent"),
|
||||
hair: choose(HAIR_COLORS, "hair"),
|
||||
bodyShape: choose(BODY_SHAPES, "body"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** Material-ready values for `buildHumanoid`, still containing no render objects. */
|
||||
export function resolveHumanoidAppearance(
|
||||
appearance: HumanoidAppearanceChoices,
|
||||
): ResolvedHumanoidAppearance {
|
||||
if (!isHumanoidAppearance(appearance)) throw new RangeError("profile: invalid humanoid appearance");
|
||||
return {
|
||||
skinTone: SKIN_COLORS[appearance.skinTone],
|
||||
outfitColor: OUTFIT_COLORS[appearance.outfit],
|
||||
accentColor: ACCENT_COLORS[appearance.accent],
|
||||
hairColor: HAIR_VALUES[appearance.hair],
|
||||
bodyShape: appearance.bodyShape,
|
||||
};
|
||||
}
|
||||
|
||||
/** Signed-in members use their humanoid; anonymous presence follows environment role. */
|
||||
export function actorKindForPresence(signedIn: boolean, context: PresenceContext): PresenceActorKind {
|
||||
if (signedIn) return "humanoid";
|
||||
return context === "office" ? "dog" : "crow";
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Exception-contained persistence over a caller-owned local storage adapter. */
|
||||
import { decodeLocalProfile, encodeLocalProfile, type LocalProfile } from "./model.ts";
|
||||
|
||||
export interface ProfileStorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
export type ProfileSaveResult = { ok: true } | { ok: false; error: string };
|
||||
export type ProfileClearResult = ProfileSaveResult;
|
||||
export type ProfileLoadResult =
|
||||
| { status: "loaded"; profile: LocalProfile; migrated: boolean }
|
||||
| { status: "missing" }
|
||||
| { status: "invalid"; error: string }
|
||||
| { status: "unavailable"; error: string };
|
||||
|
||||
function validKey(key: string): boolean {
|
||||
return typeof key === "string" && key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function saveLocalProfile(
|
||||
storage: ProfileStorageAdapter,
|
||||
key: string,
|
||||
profile: LocalProfile,
|
||||
): ProfileSaveResult {
|
||||
if (!validKey(key)) return { ok: false, error: "profile: storage key is empty" };
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = encodeLocalProfile(profile);
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "profile: encoding failed" };
|
||||
}
|
||||
try {
|
||||
storage.setItem(key, encoded);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "profile: storage is unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLocalProfile(storage: ProfileStorageAdapter, key: string): ProfileLoadResult {
|
||||
if (!validKey(key)) return { status: "invalid", error: "profile: storage key is empty" };
|
||||
let encoded: string | null;
|
||||
try {
|
||||
encoded = storage.getItem(key);
|
||||
} catch {
|
||||
return { status: "unavailable", error: "profile: storage is unavailable" };
|
||||
}
|
||||
if (encoded === null) return { status: "missing" };
|
||||
const decoded = decodeLocalProfile(encoded);
|
||||
if (!decoded.ok) return { status: "invalid", error: decoded.error };
|
||||
return { status: "loaded", profile: decoded.profile, migrated: decoded.migrated };
|
||||
}
|
||||
|
||||
export function clearLocalProfile(storage: ProfileStorageAdapter, key: string): ProfileClearResult {
|
||||
if (!validKey(key)) return { ok: false, error: "profile: storage key is empty" };
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "profile: storage is unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Ephemeral webcam-face consent state, with no media or persistence capability.
|
||||
*
|
||||
* A future browser adapter may react to an accepted `start` transition by
|
||||
* calling `getUserMedia`; this module cannot do so, cannot fetch anything, and
|
||||
* deliberately has no encode/storage API. Every fresh controller starts off.
|
||||
*/
|
||||
|
||||
export type WebcamFaceConsentStatus =
|
||||
| "off"
|
||||
| "awaiting-consent"
|
||||
| "active"
|
||||
| "stopped"
|
||||
| "revoked";
|
||||
|
||||
export interface WebcamFaceConsentState {
|
||||
status: WebcamFaceConsentStatus;
|
||||
/** True only between an explicit accepted start and stop/revoke. */
|
||||
consentGranted: boolean;
|
||||
readonly ephemeral: true;
|
||||
readonly persistence: "none";
|
||||
readonly autoFetch: false;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export type WebcamFaceConsentEvent =
|
||||
| { type: "request-start" }
|
||||
| { type: "start"; explicitConsent: true }
|
||||
| { type: "stop" }
|
||||
| { type: "revoke" };
|
||||
|
||||
export type WebcamFaceConsentRejection =
|
||||
| "already-awaiting"
|
||||
| "already-active"
|
||||
| "already-stopped"
|
||||
| "already-revoked"
|
||||
| "consent-required";
|
||||
|
||||
export type WebcamFaceConsentTransition =
|
||||
| { accepted: true; state: WebcamFaceConsentState }
|
||||
| { accepted: false; state: WebcamFaceConsentState; reason: WebcamFaceConsentRejection };
|
||||
|
||||
export interface WebcamFaceConsentController {
|
||||
state(): WebcamFaceConsentState;
|
||||
requestStart(): WebcamFaceConsentTransition;
|
||||
/** Only valid after `requestStart`; the literal true prevents accidental implicit consent. */
|
||||
start(explicitConsent: true): WebcamFaceConsentTransition;
|
||||
stop(): WebcamFaceConsentTransition;
|
||||
revoke(): WebcamFaceConsentTransition;
|
||||
}
|
||||
|
||||
export function initialWebcamFaceConsent(): WebcamFaceConsentState {
|
||||
return {
|
||||
status: "off",
|
||||
consentGranted: false,
|
||||
ephemeral: true,
|
||||
persistence: "none",
|
||||
autoFetch: false,
|
||||
revision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function copy(state: WebcamFaceConsentState): WebcamFaceConsentState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function reject(
|
||||
state: WebcamFaceConsentState,
|
||||
reason: WebcamFaceConsentRejection,
|
||||
): WebcamFaceConsentTransition {
|
||||
return { accepted: false, state: copy(state), reason };
|
||||
}
|
||||
|
||||
function accept(
|
||||
state: WebcamFaceConsentState,
|
||||
status: WebcamFaceConsentStatus,
|
||||
consentGranted: boolean,
|
||||
): WebcamFaceConsentTransition {
|
||||
return {
|
||||
accepted: true,
|
||||
state: { ...state, status, consentGranted, revision: state.revision + 1 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Pure transition primitive for UI stores and deterministic tests. */
|
||||
export function transitionWebcamFaceConsent(
|
||||
state: WebcamFaceConsentState,
|
||||
event: WebcamFaceConsentEvent,
|
||||
): WebcamFaceConsentTransition {
|
||||
switch (event.type) {
|
||||
case "request-start":
|
||||
if (state.status === "awaiting-consent") return reject(state, "already-awaiting");
|
||||
if (state.status === "active") return reject(state, "already-active");
|
||||
return accept(state, "awaiting-consent", false);
|
||||
case "start":
|
||||
if (state.status === "active") return reject(state, "already-active");
|
||||
if (state.status !== "awaiting-consent" || event.explicitConsent !== true) {
|
||||
return reject(state, "consent-required");
|
||||
}
|
||||
return accept(state, "active", true);
|
||||
case "stop":
|
||||
if (state.status === "stopped" || state.status === "off") return reject(state, "already-stopped");
|
||||
if (state.status === "revoked") return reject(state, "already-revoked");
|
||||
return accept(state, "stopped", false);
|
||||
case "revoke":
|
||||
if (state.status === "revoked") return reject(state, "already-revoked");
|
||||
return accept(state, "revoked", false);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebcamFaceConsent(): WebcamFaceConsentController {
|
||||
let current = initialWebcamFaceConsent();
|
||||
function apply(event: WebcamFaceConsentEvent): WebcamFaceConsentTransition {
|
||||
const transition = transitionWebcamFaceConsent(current, event);
|
||||
if (transition.accepted) current = transition.state;
|
||||
return { ...transition, state: copy(transition.state) };
|
||||
}
|
||||
return {
|
||||
state: () => copy(current),
|
||||
requestStart: () => apply({ type: "request-start" }),
|
||||
start: (explicitConsent) => apply({ type: "start", explicitConsent }),
|
||||
stop: () => apply({ type: "stop" }),
|
||||
revoke: () => apply({ type: "revoke" }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user