1
0

feat: add private profile media and realtime contracts

This commit is contained in:
2026-08-11 19:21:49 -07:00
parent a2a52bfdae
commit 1c37ef8f3e
22 changed files with 2321 additions and 8 deletions
+51
View File
@@ -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";
+260
View File
@@ -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";
}
+64
View File
@@ -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" };
}
}
+125
View File
@@ -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" }),
};
}