Reuse verified immutable plans and streamline cooperative hashing
Continue final hosted development reliability qualification.
This commit is contained in:
@@ -26,43 +26,80 @@ export const contentDigest = (value: unknown) =>
|
||||
* event loop while verifying large immutable metadata. No validation is skipped.
|
||||
* This is for asynchronous loading, not a different build/wire identity. */
|
||||
export async function contentDigestAsync(value: unknown): Promise<string> {
|
||||
function* parts(value: unknown): Generator<string> {
|
||||
if (Array.isArray(value)) {
|
||||
yield "[";
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (index) yield ",";
|
||||
// Match canonicalJson's Array.map behavior for sparse arrays too.
|
||||
if (index in value) yield* parts(value[index]);
|
||||
}
|
||||
yield "]";
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
||||
throw new Error("Expected a plain JSON object");
|
||||
yield "{";
|
||||
let first = true;
|
||||
for (const key of Object.keys(value).sort(compareText)) {
|
||||
const entry = (value as Record<string, unknown>)[key];
|
||||
if (entry === undefined) continue;
|
||||
if (!first) yield ",";
|
||||
first = false;
|
||||
yield JSON.stringify(key) + ":";
|
||||
yield* parts(entry);
|
||||
}
|
||||
yield "}";
|
||||
} else yield canonicalJson(value);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
// An explicit stack visits each node once; recursive generator delegation
|
||||
// otherwise forwards every token through every ancestor of a deep plan.
|
||||
type Frame = { kind: "value" | "array" | "object"; value: unknown; index?: number; keys?: string[]; wrote?: boolean };
|
||||
const hash = createHash("sha256"),
|
||||
stack: Frame[] = [{ kind: "value", value }],
|
||||
active = new WeakSet<object>();
|
||||
let buffer = "",
|
||||
sliceStarted = performance.now();
|
||||
for (const part of parts(value)) {
|
||||
sliceStarted = performance.now(),
|
||||
steps = 0;
|
||||
const append = (part: string) => {
|
||||
buffer += part;
|
||||
if (buffer.length >= 65536) {
|
||||
hash.update(buffer);
|
||||
buffer = "";
|
||||
if (performance.now() - sliceStarted >= 8) {
|
||||
await setImmediate();
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
};
|
||||
while (stack.length) {
|
||||
const frame = stack[stack.length - 1]!;
|
||||
if (frame.kind === "value") {
|
||||
const value = frame.value;
|
||||
if (value !== null && typeof value === "object") {
|
||||
if (active.has(value)) throw new Error("Cannot hash cyclic JSON values");
|
||||
active.add(value);
|
||||
frame.index = 0;
|
||||
if (Array.isArray(value)) {
|
||||
frame.kind = "array";
|
||||
append("[");
|
||||
} else {
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
||||
throw new Error("Expected a plain JSON object");
|
||||
frame.kind = "object";
|
||||
frame.keys = Object.keys(value).sort(compareText);
|
||||
frame.wrote = false;
|
||||
append("{");
|
||||
}
|
||||
} else {
|
||||
append(canonicalJson(value));
|
||||
stack.pop();
|
||||
}
|
||||
} else if (frame.kind === "array") {
|
||||
const array = frame.value as unknown[];
|
||||
if (frame.index === array.length) {
|
||||
append("]");
|
||||
active.delete(array);
|
||||
stack.pop();
|
||||
} else {
|
||||
const index = frame.index!;
|
||||
frame.index = index + 1;
|
||||
if (index) append(",");
|
||||
// Match canonicalJson's Array.map behavior for sparse arrays too.
|
||||
if (index in array) stack.push({ kind: "value", value: array[index] });
|
||||
}
|
||||
} else {
|
||||
const object = frame.value as Record<string, unknown>;
|
||||
if (frame.index === frame.keys!.length) {
|
||||
append("}");
|
||||
active.delete(object);
|
||||
stack.pop();
|
||||
} else {
|
||||
const index = frame.index!;
|
||||
frame.index = index + 1;
|
||||
const key = frame.keys![index]!,
|
||||
entry = object[key];
|
||||
if (entry !== undefined) {
|
||||
if (frame.wrote) append(",");
|
||||
frame.wrote = true;
|
||||
append(JSON.stringify(key) + ":");
|
||||
stack.push({ kind: "value", value: entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (++steps % 512 === 0 && performance.now() - sliceStarted >= 8) {
|
||||
await setImmediate();
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
}
|
||||
hash.update(buffer);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
import {
|
||||
executionPlanDigestAsync,
|
||||
type ExecutionPlan,
|
||||
type PlanPackage,
|
||||
ExecutionPlanError,
|
||||
hexDigest,
|
||||
hexDigestAsync,
|
||||
executionVersions,
|
||||
} from "./plan.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
|
||||
export interface ArtifactFile {
|
||||
path: string;
|
||||
@@ -366,7 +366,7 @@ export async function checkExecutionWorld(
|
||||
): Promise<CheckedWorld> {
|
||||
if (canonicalJson(plan.definition.versions) !== canonicalJson(executionVersions))
|
||||
fail("world", "Unsupported plan codec versions");
|
||||
if ((await hexDigestAsync(plan.definition)) !== plan.digest) fail("plan", "Plan digest mismatch");
|
||||
if ((await executionPlanDigestAsync(plan)) !== plan.digest) fail("plan", "Plan digest mismatch");
|
||||
const manifests = inputs.map(parsePackageArtifacts);
|
||||
unique(
|
||||
manifests.map((m) => m.packageRevisionId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { graphExecutionRepresentation } from "./graph-execution.js";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { hexDigest, hexDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||
import { hexDigest, hexDigestAsync, sealExecutionPlan, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||
import {
|
||||
loadExecutionInstallation,
|
||||
createClientContractCatalog,
|
||||
@@ -72,7 +72,7 @@ export async function loadCheckedInstallationCandidate(
|
||||
const [plan, artifacts, source, entryRequest, kernel, queries] = values;
|
||||
if (
|
||||
plan?.definition?.codec !== "camino-execution-plan-v1" ||
|
||||
(await hexDigestAsync(plan.definition)) !== plan.digest ||
|
||||
!(await sealExecutionPlan(plan)) ||
|
||||
workspaceDigest !== (await hexDigestAsync(plan.definition.workspace))
|
||||
)
|
||||
throw new ExecutionPlanError(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executionPlanDigest, type ExecutionPlan, resolvePlannedOperation, ExecutionPlanError } from "./plan.js";
|
||||
import type {
|
||||
InterfaceRevision,
|
||||
InterfaceOperation,
|
||||
@@ -6,7 +7,7 @@ import type {
|
||||
} from "../capability-model/types.js";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
import { portableValueSchema, tsType } from "../bindings/portable.js";
|
||||
import { type ExecutionPlan, resolvePlannedOperation, hexDigest, ExecutionPlanError } from "./plan.js";
|
||||
|
||||
import type { BindingSchema } from "../bindings/index.js";
|
||||
import type { PackageBuildContract } from "./build-contract.js";
|
||||
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
||||
@@ -55,7 +56,7 @@ export function publicOperationContract(
|
||||
}
|
||||
/** Resolve a public contract to internal dispatch. This does not authorize the receiver. */
|
||||
export function linkInterfaceOperation(plan: ExecutionPlan, atom: string, contract: PublicOperationContract) {
|
||||
if (hexDigest(plan.definition) !== plan.digest)
|
||||
if (executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("INVALID_PLAN", atom, "Invalid execution plan digest");
|
||||
const iface = plan.definition.workspace.interfaceImports.find((i) => i.revisionId === contract.interface);
|
||||
const operation = iface?.members.flatMap((m) => m.operations).find((o) => o.id === contract.operation);
|
||||
@@ -97,7 +98,7 @@ export function publicQueryContract(pkg: PackageRevision, queryId: string): Publ
|
||||
};
|
||||
}
|
||||
export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) {
|
||||
if (hexDigest(plan.definition) !== plan.digest)
|
||||
if (executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("INVALID_PLAN", contract.query, "Invalid execution plan digest");
|
||||
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === contract.package);
|
||||
if (!pkg || canonicalJson(publicQueryContract(pkg, contract.query)) !== canonicalJson(contract))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executionPlanDigestAsync, type ExecutionPlan, hexDigest, hexDigestAsync, ExecutionPlanError } from "./plan.js";
|
||||
import { portableReflectionValue } from "./reflection.js";
|
||||
import type {
|
||||
BoundDependency,
|
||||
@@ -6,7 +7,6 @@ import type {
|
||||
PersistentAttachment,
|
||||
} from "../capability-model/types.js";
|
||||
import { type ExecutionInstallation, type ExecutionGroupIdentity } from "./installation.js";
|
||||
import { type ExecutionPlan, hexDigest, hexDigestAsync, ExecutionPlanError } from "./plan.js";
|
||||
|
||||
export interface ExecutionEnvironment {
|
||||
/** Explicit host placement. Merely including an optional module does not select it. */
|
||||
@@ -28,7 +28,7 @@ export async function compileExecutionGroups(
|
||||
installation: ExecutionInstallation,
|
||||
environments: Readonly<Record<string, ExecutionEnvironment>>,
|
||||
): Promise<InstalledExecutionGroup[]> {
|
||||
if (installation.plan !== plan.digest || (await hexDigestAsync(plan.definition)) !== plan.digest)
|
||||
if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest)
|
||||
throw new ExecutionPlanError("GROUP_PLAN_MISMATCH", plan.digest, "Expected one checked installation");
|
||||
const workspace = plan.definition.workspace;
|
||||
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId as string, p]));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
|
||||
import type { ExecutionInstallation } from "./installation.js";
|
||||
|
||||
/** Execution inputs, not build evidence. Nominal revision IDs remain dispatch
|
||||
@@ -10,7 +11,7 @@ export function graphExecutionRepresentation(
|
||||
kernel: unknown,
|
||||
queries: unknown,
|
||||
) {
|
||||
if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest)
|
||||
if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("GRAPH_PLAN_MISMATCH", "graph", "Expected one checked installation");
|
||||
if (
|
||||
!kernel ||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { executionPlanDigestAsync, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||
import { compileInspectionTemplates, type InspectionTemplate, type InspectionKernel } from "./inspection.js";
|
||||
import path from "node:path";
|
||||
import { loadCheckedArtifactWorld } from "./receipts.js";
|
||||
import { contentArtifactUrl, loadArtifactWorld, type LoadedArtifactWorld } from "./files.js";
|
||||
import { selectArtifactImplementation, type ArtifactImplementation, type ComponentArtifact } from "./artifacts.js";
|
||||
import { ExecutionPlanError, hexDigestAsync, type ExecutionPlan } from "./plan.js";
|
||||
|
||||
import {
|
||||
publicOperationContract,
|
||||
publicQueryContract,
|
||||
@@ -47,7 +48,7 @@ export async function clientContractCatalog(
|
||||
requestedPackages: readonly string[],
|
||||
checkedKernel?: InspectionKernel,
|
||||
): Promise<ClientContractCatalog> {
|
||||
if (installation.plan !== plan.digest || (await hexDigestAsync(plan.definition)) !== plan.digest)
|
||||
if (installation.plan !== plan.digest || (await executionPlanDigestAsync(plan)) !== plan.digest)
|
||||
throw new ExecutionPlanError(
|
||||
"CATALOG_PLAN_MISMATCH",
|
||||
plan.digest,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { executionPlanDigest, type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
|
||||
import { portableReflectionType, portableReflectionValue } from "./reflection.js";
|
||||
import type {
|
||||
ValueType,
|
||||
@@ -7,7 +8,7 @@ import type {
|
||||
BoundDependency,
|
||||
PackageExport,
|
||||
} from "../capability-model/types.js";
|
||||
import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
|
||||
|
||||
import { portableValueSchema } from "../bindings/portable.js";
|
||||
import { subscriptionContract } from "../capability-model/journals.js";
|
||||
type PreparedStep = string | null | { case: string; id: string };
|
||||
@@ -25,7 +26,7 @@ function preparedInputPaths(type: ValueType, path: PreparedStep[] = []): Prepare
|
||||
}
|
||||
/** Small data-only interpreter input; no compiler, parser, Node module or SQL dependency reaches the browser. */
|
||||
export function compileKernelProgram(plan: ExecutionPlan): unknown {
|
||||
if (hexDigest(plan.definition) !== plan.digest)
|
||||
if (executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("INVALID_PLAN", "kernel", "Plan digest mismatch");
|
||||
const workspace = plan.definition.workspace;
|
||||
if (workspace.sharedAttachments.some((attachment) => attachment.kind !== "edge"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { executionPlanDigest, ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
import { parse } from "@babel/parser";
|
||||
import { builtinModules } from "node:module";
|
||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
|
||||
import type { ExecutionInstallation } from "./installation.js";
|
||||
import type { ArtifactImplementation, ArtifactReader } from "./artifacts.js";
|
||||
|
||||
@@ -212,7 +213,7 @@ export async function reportExecutionPlacement(
|
||||
installation: ExecutionInstallation,
|
||||
read: ArtifactReader,
|
||||
) {
|
||||
if (installation.plan !== plan.digest || hexDigest(plan.definition) !== plan.digest)
|
||||
if (installation.plan !== plan.digest || executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError(
|
||||
"PLACEMENT_PLAN_MISMATCH",
|
||||
plan.digest,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { linkQueries, type LinkedQuery } from "../query/link.js";
|
||||
import { type BindingSchema } from "../bindings/index.js";
|
||||
import { unsupportedQueryOperator } from "./query-operators.js";
|
||||
import { packageBuildContexts } from "./build-contract.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
export class ExecutionPlanError extends Error {
|
||||
constructor(
|
||||
@@ -31,6 +32,54 @@ export class ExecutionPlanError extends Error {
|
||||
export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length);
|
||||
export const hexDigestAsync = async (value: unknown): Promise<string> =>
|
||||
(await contentDigestAsync(value)).slice("sha256:".length);
|
||||
// Checked runtime metadata is owned JSON, not live Graph data. Reuse its proof
|
||||
// only after every reachable record is immutable. Weak keys retain neither old
|
||||
// Candidates nor an application cache; unsealed compiler inputs are rehashed.
|
||||
const sealedPlanDigests = new WeakMap<ExecutionPlan, string>();
|
||||
export const executionPlanDigest = (plan: ExecutionPlan): string =>
|
||||
sealedPlanDigests.get(plan) ?? hexDigest(plan.definition);
|
||||
export const executionPlanDigestAsync = async (plan: ExecutionPlan): Promise<string> =>
|
||||
sealedPlanDigests.get(plan) ?? (await hexDigestAsync(plan.definition));
|
||||
/** Transfers owned parsed JSON into immutable runtime metadata. Freeze before
|
||||
* yielding to hashing so concurrent mutation cannot invalidate the proof. */
|
||||
export async function sealExecutionPlan(plan: ExecutionPlan): Promise<boolean> {
|
||||
if (sealedPlanDigests.has(plan)) return true;
|
||||
if (!Object.hasOwn(plan, "definition") || !Object.hasOwn(plan, "digest")) return false;
|
||||
const pending: object[] = [plan],
|
||||
seen = new WeakSet<object>();
|
||||
let sliceStarted = performance.now(),
|
||||
count = 0;
|
||||
while (pending.length) {
|
||||
const value = pending.pop()!;
|
||||
if (seen.has(value)) continue;
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null
|
||||
)
|
||||
throw new ExecutionPlanError("INVALID_PLAN_JSON", "plan", "Expected owned JSON records");
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
||||
if (typeof key !== "string" || !Object.hasOwn(descriptor, "value"))
|
||||
throw new ExecutionPlanError(
|
||||
"INVALID_PLAN_JSON",
|
||||
"plan",
|
||||
"Runtime metadata cannot contain accessors or symbols",
|
||||
);
|
||||
if (descriptor.value !== null && typeof descriptor.value === "object") pending.push(descriptor.value);
|
||||
}
|
||||
Object.freeze(value);
|
||||
seen.add(value);
|
||||
if (++count % 1024 === 0 && performance.now() - sliceStarted >= 8) {
|
||||
await setImmediate();
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
}
|
||||
const digest = await hexDigestAsync(plan.definition);
|
||||
if (digest !== plan.digest) return false;
|
||||
sealedPlanDigests.set(plan, digest);
|
||||
return true;
|
||||
}
|
||||
const sorted = <T>(values: readonly T[], key: (value: T) => string): T[] =>
|
||||
[...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
||||
export const publicOperationKey = (atom: string, iface: string, operation: string): string =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js";
|
||||
import { executionPlanDigest, ExecutionPlanError, type ExecutionPlan, type PlannedQuery } from "./plan.js";
|
||||
|
||||
/** Contracts promised by a direct result. Interface dependencies are explicit; accidental
|
||||
* overlap among today's implementations does not silently widen an exported interface contract. */
|
||||
export function resultInterfaces(plan: ExecutionPlan, operationKey: string): string[] {
|
||||
@@ -23,7 +24,7 @@ export function resultInterfaces(plan: ExecutionPlan, operationKey: string): str
|
||||
}
|
||||
/** Compile-time observeResult compatibility. Observation does not grant read or mutation authority. */
|
||||
export function checkResultProjection(plan: ExecutionPlan, operationKey: string, queryId: string): PlannedQuery {
|
||||
if (hexDigest(plan.definition) !== plan.digest)
|
||||
if (executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("INVALID_PLAN", "projection", "Plan digest mismatch");
|
||||
const query = plan.definition.queries.find((q) => q.id === queryId);
|
||||
if (!query) throw new ExecutionPlanError("UNKNOWN_PROJECTION", queryId, "No installed query");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { executionPlanDigest, ExecutionPlanError, type ExecutionPlan } from "./plan.js";
|
||||
import { compileKernelProgram } from "./kernel.js";
|
||||
import { compileInspectionTemplates } from "./inspection.js";
|
||||
import { portableValueSchema } from "../bindings/portable.js";
|
||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
|
||||
import type { ValueType } from "../capability-model/types.js";
|
||||
import { compileQueryComposition } from "../query/composition.js";
|
||||
import { queryTreeBlueprint } from "./query-trees.js";
|
||||
@@ -24,7 +25,7 @@ const lowerTypes = (value: unknown): unknown =>
|
||||
/** Checked query IR and installed getter identities, without GraphQL schemas/parsers or SQL adapters.
|
||||
* Keep authored selection names only for output paths; field access uses resolved interface/member IDs. */
|
||||
export function compileQueryProgram(plan: ExecutionPlan): unknown {
|
||||
if (hexDigest(plan.definition) !== plan.digest)
|
||||
if (executionPlanDigest(plan) !== plan.digest)
|
||||
throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch");
|
||||
const allQueries = new Map(plan.definition.queries.map(({ id, query }) => [id, query.checked]));
|
||||
const compositions = plan.definition.workspace.packageImports.flatMap((pkg) =>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { executionPlanDigest, hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js";
|
||||
import { compileQuery } from "../query/compile.js";
|
||||
import { linkQueries } from "../query/link.js";
|
||||
import { capabilityId } from "../capability-model/types.js";
|
||||
import { portableValueSchema } from "../bindings/portable.js";
|
||||
import { hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js";
|
||||
|
||||
import { compileKernelProgram } from "./kernel.js";
|
||||
import { queryProgramEntry } from "./queries.js";
|
||||
import { unsupportedQueryOperator } from "./query-operators.js";
|
||||
@@ -17,7 +18,7 @@ export interface QueryDocument {
|
||||
/** Runtime query documents use the same language, nominal contracts, linking
|
||||
* and interpreter as packaged queries. They introduce no resources or code. */
|
||||
export async function compilePublicQuery(plan: ExecutionPlan, request: QueryDocument) {
|
||||
if (hexDigest(plan.definition) !== plan.digest) throw Error("INVALID_PLAN");
|
||||
if (executionPlanDigest(plan) !== plan.digest) throw Error("INVALID_PLAN");
|
||||
if (
|
||||
typeof request.document !== "string" ||
|
||||
Buffer.byteLength(request.document) > 32768 ||
|
||||
|
||||
Reference in New Issue
Block a user