import { Environment } from "@marcbachmann/cel-js"; import { uuid } from "@noya-app/noya-utils"; import { TSchema } from "@sinclair/typebox"; import { ExtendedPatch, ExtendedPathKey, extractItemId, toSimplePath, } from "./extended"; import type { AccessType } from "./sync/types"; export const MULTIPLAYER_POLICY_METADATA_KEY = "noyaPolicy"; export const SERVER_COMPUTED_METADATA_KEY = "noyaServerComputed"; export const SERVER_SCRIPTS_METADATA_KEY = "noyaServerScripts"; export type MultiplayerPolicyAction = "read" | "create" | "update" | "delete"; export type MultiplayerPolicyLet = [string, string]; export type MultiplayerPolicyDefinition = { allow?: Partial>; let?: MultiplayerPolicyLet[] | Record; }; export type PolicyAuthContext = { id?: string | null; access?: AccessType; [key: string]: unknown; }; export type ServerComputedEvent = "create" | "update"; export type ServerComputedTarget = "value" | "object"; export type ServerComputedValueFactory = | { kind: "timestamp"; format?: "iso" | "number" } | { kind: "authId" } | { kind: "uuid" } | { kind: "literal"; value: unknown } | { kind: "expression"; expression: string }; export type ServerComputedDefinition = { events?: ServerComputedEvent[]; targets?: ServerComputedTarget[]; compute: ServerComputedValueFactory; }; export type ServerScriptDefinition = { id: string; code: string; label?: string; on?: { interval?: string; stateChange?: true | { paths: string[] }; inputQueue?: true | { queues: string[] }; user?: true | { events: ("join" | "leave")[] }; schemaMigration?: true | { fromVersions?: number[] }; }; }; type ServerComputedDefinitionInput = Omit< ServerComputedDefinition, "compute" > & { compute: ServerComputedValueFactory | string; }; function normalizeServerComputedDefinition( definition: ServerComputedDefinitionInput ): ServerComputedDefinition { const compute: ServerComputedValueFactory = typeof definition.compute === "string" ? { kind: "expression", expression: definition.compute } : definition.compute; return { ...definition, compute }; } export function ServerComputed( schema: T, definition: ServerComputedDefinitionInput ) { (schema as SchemaWithMetadata)[SERVER_COMPUTED_METADATA_KEY] = normalizeServerComputedDefinition(definition); return schema; } export function AccessPolicy( schema: T, definition: MultiplayerPolicyDefinition ) { (schema as SchemaWithMetadata)[MULTIPLAYER_POLICY_METADATA_KEY] = definition; return schema; } export function ServerScripts( schema: T, scripts: ServerScriptDefinition[] ) { (schema as SchemaWithMetadata)[SERVER_SCRIPTS_METADATA_KEY] = scripts; return schema; } type SchemaWithMetadata = TSchema & { [MULTIPLAYER_POLICY_METADATA_KEY]?: MultiplayerPolicyDefinition; [SERVER_COMPUTED_METADATA_KEY]?: ServerComputedDefinition; [SERVER_SCRIPTS_METADATA_KEY]?: ServerScriptDefinition[]; }; export function getServerScriptDefinitions(schema?: TSchema | null) { if (!schema) return []; const scripts = (schema as SchemaWithMetadata)[SERVER_SCRIPTS_METADATA_KEY] ?? []; return scripts.filter( (script): script is ServerScriptDefinition => !!script && typeof script.id === "string" && typeof script.code === "string" ); } function normalizePolicyLet( value: MultiplayerPolicyDefinition["let"] ): MultiplayerPolicyLet[] { if (!value) return []; if (Array.isArray(value)) return value; return Object.entries(value); } function determineRequiredBindings( expression: string | undefined, entries: MultiplayerPolicyLet[] ) { const names = entries.map(([name]) => name); const expressionMap = new Map(entries); const required = new Set(); const referenced = findBindingReferences(expression, names); for (const name of referenced) { addBindingWithDependencies(name, required, expressionMap, names, new Set()); } return required; } function addBindingWithDependencies( name: string, required: Set, expressionMap: Map, names: string[], visiting: Set ) { if (required.has(name)) return; if (visiting.has(name)) return; visiting.add(name); const dependencies = findBindingReferences(expressionMap.get(name), names); for (const dependency of dependencies) { addBindingWithDependencies( dependency, required, expressionMap, names, visiting ); } visiting.delete(name); required.add(name); } function findBindingReferences( expression: string | undefined, names: string[] ) { if (!expression) return []; const matches: string[] = []; for (const name of names) { const pattern = new RegExp(`\\b${escapeRegExp(name)}\\b`); if (pattern.test(expression)) { matches.push(name); } } return matches; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } type SchemaPathNode = { schema: SchemaWithMetadata; /** Number of path segments consumed to reach this schema node. */ pathLength: number; }; type SchemaTraversal = SchemaPathNode[]; type ExpressionContext = { auth: PolicyAuthContext; data: unknown; newData: unknown; patch?: ExtendedPatch; now: number; bindings: Record; }; const celEnvironment = new Environment({ unlistedVariablesAreDyn: true, }); const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); celEnvironment .registerFunction("random(): double", () => Math.random()) .registerFunction("randomInt(int, int): int", (min: bigint, max: bigint) => { if (max < min) { throw new Error( "randomInt requires the second argument to be greater than or equal to the first" ); } if (max === min) { return min; } const span = max - min + 1n; if (span > MAX_SAFE_INTEGER_BIGINT) { throw new Error("randomInt range is too large to generate safely"); } const spanNumber = Number(span); const offset = BigInt(Math.floor(Math.random() * spanNumber)); return min + offset; }) .registerFunction( "now(): double", function (this: { ctx: { getVariable: (name: string) => { value: unknown } | undefined }; }) { const nowVariable = this.ctx.getVariable("now"); const nowValue = nowVariable?.value ?? Date.now(); return typeof nowValue === "number" ? nowValue : Number(nowValue); } ) .registerFunction( "timestamp(): string", function (this: { ctx: { getVariable: (name: string) => { value: unknown } | undefined }; }) { const nowVariable = this.ctx.getVariable("now"); const nowValue = typeof nowVariable?.value === "number" ? nowVariable.value : typeof nowVariable?.value === "string" ? Number(nowVariable.value) : Date.now(); return new Date(nowValue).toISOString(); } ) .registerFunction("uuid(): string", () => uuid()); type ParsedExpression = ReturnType; const expressionCache = new Map(); function getParsedExpression(expression: string) { let parsed = expressionCache.get(expression); if (!parsed) { parsed = celEnvironment.parse(expression); expressionCache.set(expression, parsed); } return parsed; } function evaluateExpression(expression: string, context: ExpressionContext) { const parsed = getParsedExpression(expression); return parsed({ auth: context.auth ?? {}, data: context.data ?? null, newData: context.newData ?? null, patch: context.patch ?? null, now: context.now, ...context.bindings, }); } type PolicyEvaluationResult = { allowed: boolean; expression?: string; error?: string; }; function evaluatePolicyRule(options: { policy: MultiplayerPolicyDefinition; action: MultiplayerPolicyAction; context: Omit; }): PolicyEvaluationResult { const allowExpression = options.policy.allow?.[options.action]; if (!allowExpression) { return { allowed: true }; } const bindingEntries = normalizePolicyLet(options.policy.let); const requiredBindings = determineRequiredBindings( allowExpression, bindingEntries ); const bindings: Record = {}; const context: ExpressionContext = { ...options.context, bindings, }; try { for (const [name, expression] of bindingEntries) { if (!requiredBindings.has(name)) continue; bindings[name] = evaluateExpression(expression, context); } const allowed = Boolean(evaluateExpression(allowExpression, context)); return { allowed, expression: allowExpression }; } catch (error) { return { allowed: false, expression: allowExpression, error: error instanceof Error ? error.message : String(error), }; } } export type PolicyViolation = { action: MultiplayerPolicyAction; patch: ExtendedPatch; expression?: string; message?: string; }; export function enforceMultiplayerPolicies(options: { schema?: TSchema; patches: ExtendedPatch[]; previousState: S; nextState: S; auth?: PolicyAuthContext; now: number; }): PolicyViolation | undefined { const { schema, patches, previousState, nextState, auth, now } = options; if (!schema) return undefined; for (const patch of patches) { const schemaPath = findSchemaPath(schema, patch.path); if (schemaPath.length === 0) continue; const action = inferPolicyAction(patch, schemaPath); if (!action) continue; const policyNode = findPolicyNode(schemaPath); if (!policyNode?.schema[MULTIPLAYER_POLICY_METADATA_KEY]) { if ((auth?.access ?? "write") !== "write") { return { action: action ?? "update", patch, message: "Insufficient access level to edit this field", }; } continue; } const policy = policyNode.schema[MULTIPLAYER_POLICY_METADATA_KEY]!; const simplePath = toSimplePath(previousState, patch.path); const policyPath = simplePath.slice(0, policyNode.pathLength); const previousValue = getValueAtPath(previousState, policyPath); const nextValue = getValueAtPath(nextState, policyPath); const evaluation = evaluatePolicyRule({ policy, action, context: { auth: auth ?? {}, data: previousValue, newData: nextValue, patch, now, }, }); if (!evaluation.allowed) { return { action, patch, expression: evaluation.expression, ...(evaluation.error && { message: evaluation.error }), }; } } return undefined; } export function applyServerComputedMetadata(options: { schema?: TSchema; patches: ExtendedPatch[]; auth?: PolicyAuthContext; now: number; }): ExtendedPatch[] { const { schema, patches, auth, now } = options; if (!schema) return []; const extraPatches: ExtendedPatch[] = []; for (const patch of patches) { const schemaPath = findSchemaPath(schema, patch.path); if (schemaPath.length === 0) continue; const event = inferServerEvent(patch, schemaPath); if (!event) continue; const targetSchema = schemaPath[schemaPath.length - 1]?.schema; if (targetSchema && "value" in patch) { patch.value = applyServerComputedToValue( targetSchema, patch.value, event, auth ?? {}, now ); } extraPatches.push( ...createObjectScopePatches({ schemaPath, patch, event, auth: auth ?? {}, now, }) ); } return extraPatches; } const READ_DENIED = Symbol("readDenied"); export function filterStateForReadAccess(options: { schema?: TSchema; state: S; auth?: PolicyAuthContext; now: number; }): S { const { schema, state, auth, now } = options; if (!schema) return state; const traversal: SchemaTraversal = [ { schema: schema as SchemaWithMetadata, pathLength: 0 }, ]; const filtered = filterValueForRead({ schemaPath: traversal, value: state, rootState: state, simplePath: [], auth: auth ?? {}, now, }); if (filtered === READ_DENIED) { return undefined as S; } return filtered as S; } type FilterNodeOptions = { schemaPath: SchemaTraversal; value: unknown; rootState: unknown; simplePath: (string | number)[]; auth: PolicyAuthContext; now: number; }; function filterValueForRead(options: FilterNodeOptions): unknown { const canRead = canReadPath({ schemaPath: options.schemaPath, simplePath: options.simplePath, state: options.rootState, auth: options.auth, now: options.now, }); if (!canRead) { return READ_DENIED; } const currentSchema = options.schemaPath.at(-1)?.schema; if (!currentSchema) { return options.value; } if (Array.isArray(options.value) && isArraySchema(currentSchema)) { let changed = false; const filteredItems: unknown[] = []; for (let index = 0; index < options.value.length; index++) { const item = options.value[index]; const itemId = extractItemId(item); const segment: ExtendedPathKey = itemId !== undefined ? { id: itemId } : index; const childSchema = getChildSchema(currentSchema, segment); const childSchemaPath = childSchema ? [ ...options.schemaPath, { schema: childSchema, pathLength: options.simplePath.length + 1 }, ] : options.schemaPath; const filtered = filterValueForRead({ schemaPath: childSchemaPath, value: item, rootState: options.rootState, simplePath: [...options.simplePath, index], auth: options.auth, now: options.now, }); if (filtered === READ_DENIED) { changed = true; continue; } filteredItems.push(filtered); if (filtered !== item) { changed = true; } } if (!changed) { return options.value; } return filteredItems; } if (isPlainObject(options.value) && isObjectSchema(currentSchema)) { let changed = false; let result: Record | undefined; const ensureResult = () => { if (!result) { result = { ...(options.value as Record) }; } return result; }; for (const key of Object.keys(options.value as Record)) { const childValue = (options.value as Record)[key]; const childSchema = currentSchema.properties?.[key] as | SchemaWithMetadata | undefined; const childSchemaPath = childSchema ? [ ...options.schemaPath, { schema: childSchema, pathLength: options.simplePath.length + 1 }, ] : options.schemaPath; const filtered = filterValueForRead({ schemaPath: childSchemaPath, value: childValue, rootState: options.rootState, simplePath: [...options.simplePath, key], auth: options.auth, now: options.now, }); if (filtered === READ_DENIED) { const target = ensureResult(); delete target[key]; changed = true; continue; } if (filtered !== childValue) { const target = ensureResult(); target[key] = filtered; changed = true; } } if (!changed) { return options.value; } return result ?? options.value; } return options.value; } function canReadPath(options: { schemaPath: SchemaTraversal; simplePath: (string | number)[]; state: unknown; auth: PolicyAuthContext; now: number; }) { const policyNode = findPolicyNode(options.schemaPath); if (!policyNode?.schema[MULTIPLAYER_POLICY_METADATA_KEY]) { return true; } const policy = policyNode.schema[MULTIPLAYER_POLICY_METADATA_KEY]!; const policyPath = options.simplePath.slice(0, policyNode.pathLength); const value = getValueAtPath(options.state, policyPath); const evaluation = evaluatePolicyRule({ policy, action: "read", context: { auth: options.auth ?? {}, data: value, newData: value, now: options.now, }, }); if (evaluation.error) { return false; } return evaluation.allowed; } function createObjectScopePatches(options: { schemaPath: SchemaTraversal; patch: ExtendedPatch; event: ServerComputedEvent; auth: PolicyAuthContext; now: number; }): ExtendedPatch[] { const { schemaPath, patch, event, auth, now } = options; const patches: ExtendedPatch[] = []; for (const node of schemaPath) { if (!isObjectSchema(node.schema)) continue; const properties = node.schema.properties ?? {}; const patchTargetsNode = patch.path.length === node.pathLength; const patchValue = patchTargetsNode ? patch.value : undefined; for (const key of Object.keys(properties)) { const propertySchema = properties[key] as SchemaWithMetadata; const metadata = propertySchema[SERVER_COMPUTED_METADATA_KEY]; if (!metadata) continue; if (!shouldApplyServerComputed(metadata, event, "object")) continue; const coversValueTarget = (metadata.targets ?? ["value"]).includes("value") && patchTargetsNode && isPlainObject(patchValue) && Object.prototype.hasOwnProperty.call(patchValue, key) && event === "create"; if (coversValueTarget) { continue; } patches.push({ op: "add", path: [...patch.path.slice(0, node.pathLength), key], value: computeServerValue(metadata.compute, { auth, now }), }); } } return patches; } function shouldApplyServerComputed( metadata: ServerComputedDefinition, event: ServerComputedEvent, target: ServerComputedTarget ) { const events = metadata.events ?? ["create"]; const targets = metadata.targets ?? ["value"]; return events.includes(event) && targets.includes(target); } function computeServerValue( factory: ServerComputedValueFactory, context: { auth: PolicyAuthContext; now: number } ) { switch (factory.kind) { case "timestamp": { if (factory.format === "number") { return context.now; } return new Date(context.now).toISOString(); } case "authId": { return context.auth.id ?? null; } case "uuid": { return uuid(); } case "literal": { return factory.value; } case "expression": { return evaluateExpression(factory.expression, { auth: context.auth, data: undefined, newData: undefined, patch: undefined, now: context.now, bindings: {}, }); } } } function applyServerComputedToValue( schema: SchemaWithMetadata, value: any, event: ServerComputedEvent, auth: PolicyAuthContext, now: number ) { const metadata = schema[SERVER_COMPUTED_METADATA_KEY]; if (metadata && shouldApplyServerComputed(metadata, event, "value")) { return computeServerValue(metadata.compute, { auth, now }); } if (isObjectSchema(schema) && isPlainObject(value)) { const properties = schema.properties ?? {}; for (const key of Object.keys(value)) { const child = properties[key] as SchemaWithMetadata | undefined; if (!child) continue; value[key] = applyServerComputedToValue( child, value[key], event, auth, now ); } } else if (isArraySchema(schema) && Array.isArray(value)) { const itemSchema = schema.items as SchemaWithMetadata | undefined; if (itemSchema) { for (let i = 0; i < value.length; i++) { value[i] = applyServerComputedToValue( itemSchema, value[i], event, auth, now ); } } } return value; } function findPolicyNode(schemaPath: SchemaTraversal) { for (let i = schemaPath.length - 1; i >= 0; i--) { const node = schemaPath[i]; if (node.schema[MULTIPLAYER_POLICY_METADATA_KEY]) { return node; } } } function inferPolicyAction( patch: ExtendedPatch, nodes: SchemaTraversal ): MultiplayerPolicyAction | undefined { if (patch.op === "remove") return "delete"; const event = inferServerEvent(patch, nodes); if (!event) return undefined; switch (event) { case "create": return "create"; case "update": return "update"; } } function inferServerEvent( patch: ExtendedPatch, nodes: SchemaTraversal ): ServerComputedEvent | undefined { const lastNode = nodes[nodes.length - 1]; switch (patch.op) { case "add": return isObjectSchema(lastNode.schema) ? "create" : "update"; case "replace": return "update"; default: return undefined; } } function findSchemaPath(schema: TSchema, path: ExtendedPathKey[]) { const traversal: SchemaTraversal = [ { schema: schema as SchemaWithMetadata, pathLength: 0 }, ]; let current: SchemaWithMetadata | undefined = schema as SchemaWithMetadata; for (let i = 0; i < path.length; i++) { current = getChildSchema(current, path[i]); if (!current) break; traversal.push({ schema: current, pathLength: i + 1 }); } return traversal; } function getChildSchema( schema: SchemaWithMetadata | undefined, segment: ExtendedPathKey ) { if (!schema) return undefined; if (Array.isArray((schema as any).anyOf) && (schema as any).anyOf[0]) { return getChildSchema((schema as any).anyOf[0], segment); } if (isArraySchema(schema)) { return schema.items as SchemaWithMetadata; } if (isObjectSchema(schema) && typeof segment === "string") { return (schema.properties?.[segment] as SchemaWithMetadata) ?? undefined; } return undefined; } function isObjectSchema(schema: TSchema): schema is TSchema & { type: "object"; properties?: Record; } { return (schema as any).type === "object"; } function isArraySchema(schema: TSchema): schema is TSchema & { type: "array"; items?: SchemaWithMetadata; } { return (schema as any).type === "array"; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function getValueAtPath(obj: unknown, path: (string | number)[]) { let current = obj as any; for (const segment of path) { if (current === undefined || current === null) return undefined; current = current[segment as keyof typeof current]; } return current; }