Initialize Browsec architecture and TrustLab
This commit is contained in:
commit
a1e56c1d09
14 files changed
+1500
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# TrustLab
|
||||
|
||||
TrustLab is the browser-neutral executable model of Browsec's trust-plugin
|
||||
protocol. It currently runs under Node.js without external dependencies, but
|
||||
plugins receive only portable JavaScript values and TrustLab capabilities.
|
||||
|
||||
The initial runner separates two phases:
|
||||
|
||||
1. Evidence plugins independently inspect the same immutable TLS facts. Their
|
||||
attributed results are appended deterministically to the journal.
|
||||
2. Decision plugins inspect the sealed evidence view in configured order. The
|
||||
first authorized Boolean verdict is terminal. Browsec's immutable built-in
|
||||
handler finalizes that result or supplies the safe fallback.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm run demo
|
||||
```
|
||||
|
||||
## Current protocol boundary
|
||||
|
||||
Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They
|
||||
cannot access Node.js facilities through the protocol, mutate TLS facts or
|
||||
journal entries, or perform browser actions.
|
||||
|
||||
This first slice intentionally omits persistence, package signatures,
|
||||
interactive UI, community identities, networking, and real X.509 parsing.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { unknownLocalAuthority } from "../fixtures/tls.js";
|
||||
import { TrustRunner } from "../src/index.js";
|
||||
|
||||
const villagePlugin = {
|
||||
manifest: {
|
||||
id: "community.village.trust",
|
||||
name: "Village community trust",
|
||||
role: "decision-authority",
|
||||
},
|
||||
collectEvidence({ facts }) {
|
||||
if (facts.hostname !== "library.village") return;
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
kind: "evidence",
|
||||
code: "known-community-key",
|
||||
message: "The community has previously observed this certificate.",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
decide({ facts, journal }) {
|
||||
const known = journal.entries.some(
|
||||
(entry) => entry.code === "known-community-key",
|
||||
);
|
||||
if (!known) return;
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
reasonEntryIds: journal.entries
|
||||
.filter((entry) => entry.code === "known-community-key")
|
||||
.map((entry) => entry.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await new TrustRunner({ plugins: [villagePlugin] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const validPublicCertificate = {
|
||||
connectionId: "connection-valid-public",
|
||||
hostname: "example.test",
|
||||
port: 443,
|
||||
validation: "success",
|
||||
errors: [],
|
||||
presentedChain: [
|
||||
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
||||
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
||||
],
|
||||
constructedChain: [
|
||||
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
||||
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
||||
{ subject: "CN=Example Root", sha256: "root-public" },
|
||||
],
|
||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||
};
|
||||
|
||||
export const unknownLocalAuthority = {
|
||||
connectionId: "connection-unknown-local",
|
||||
hostname: "library.village",
|
||||
port: 443,
|
||||
validation: "failure",
|
||||
errors: ["unknown-issuer"],
|
||||
presentedChain: [
|
||||
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
||||
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
||||
],
|
||||
constructedChain: [
|
||||
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
||||
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
||||
],
|
||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@browsec/trustlab",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Browser-neutral reference runner for Browsec trust plugins",
|
||||
"scripts": {
|
||||
"test": "node --test --test-isolation=none",
|
||||
"demo": "node examples/demo.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const BUILTIN_HANDLER_ID = "org.browsec.builtin-final-handler";
|
||||
|
||||
export function createBuiltinFinalHandler() {
|
||||
return Object.freeze({
|
||||
manifest: {
|
||||
id: BUILTIN_HANDLER_ID,
|
||||
name: "Browsec built-in final handler",
|
||||
role: "decision-authority",
|
||||
immutable: true,
|
||||
},
|
||||
|
||||
async finalize({ facts, journal, terminalVerdict }) {
|
||||
if (terminalVerdict) return terminalVerdict;
|
||||
|
||||
return {
|
||||
trusted: facts.validation === "success",
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
reasonEntryIds: journal.entries
|
||||
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
|
||||
.map((entry) => entry.id),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Return a deeply frozen clone suitable for crossing a plugin boundary. */
|
||||
export function immutableClone(value) {
|
||||
return deepFreeze(structuredClone(value));
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const child of Object.values(value)) {
|
||||
deepFreeze(child);
|
||||
}
|
||||
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
||||
export { DecisionJournal } from "./journal.js";
|
||||
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
|
||||
export { TrustRunner } from "./runner.js";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { immutableClone } from "./immutable.js";
|
||||
|
||||
export class DecisionJournal {
|
||||
#entries = [];
|
||||
#sealed = false;
|
||||
|
||||
append(plugin, entries) {
|
||||
if (this.#sealed) throw new Error("Decision journal is sealed");
|
||||
|
||||
const appended = entries.map((entry) => {
|
||||
const record = immutableClone({
|
||||
id: `entry-${this.#entries.length + 1}`,
|
||||
pluginId: plugin.id,
|
||||
pluginName: plugin.name,
|
||||
kind: entry.kind,
|
||||
code: entry.code,
|
||||
message: entry.message,
|
||||
data: entry.data,
|
||||
});
|
||||
this.#entries.push(record);
|
||||
return record;
|
||||
});
|
||||
|
||||
return immutableClone(appended);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return immutableClone({ entries: this.#entries });
|
||||
}
|
||||
|
||||
seal() {
|
||||
this.#sealed = true;
|
||||
return this.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { immutableClone } from "./immutable.js";
|
||||
|
||||
export const ENTRY_KINDS = Object.freeze([
|
||||
"evidence",
|
||||
"warning",
|
||||
"vote",
|
||||
"resolution",
|
||||
"error",
|
||||
"abstention",
|
||||
]);
|
||||
|
||||
export const PLUGIN_ROLES = Object.freeze([
|
||||
"observer",
|
||||
"advisor",
|
||||
"decision-authority",
|
||||
"veto-authority",
|
||||
]);
|
||||
|
||||
export function createTlsFacts(input) {
|
||||
requireString(input?.connectionId, "connectionId");
|
||||
requireString(input?.hostname, "hostname");
|
||||
requireInteger(input?.port, "port");
|
||||
requireString(input?.validation, "validation");
|
||||
|
||||
return immutableClone({
|
||||
schemaVersion: 0,
|
||||
connectionId: input.connectionId,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
validation: input.validation,
|
||||
errors: input.errors ?? [],
|
||||
presentedChain: input.presentedChain ?? [],
|
||||
constructedChain: input.constructedChain ?? [],
|
||||
tls: input.tls ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
export function validateManifest(manifest) {
|
||||
requireString(manifest?.id, "plugin manifest id");
|
||||
requireString(manifest?.name, "plugin manifest name");
|
||||
if (!PLUGIN_ROLES.includes(manifest.role)) {
|
||||
throw new TypeError(`Unsupported plugin role: ${manifest.role}`);
|
||||
}
|
||||
return immutableClone(manifest);
|
||||
}
|
||||
|
||||
export function validateContribution(contribution, role) {
|
||||
if (contribution === undefined) return immutableClone({ entries: [] });
|
||||
if (contribution === null || typeof contribution !== "object") {
|
||||
throw new TypeError("Plugin contribution must be an object");
|
||||
}
|
||||
|
||||
const entries = contribution.entries ?? [];
|
||||
if (!Array.isArray(entries)) throw new TypeError("entries must be an array");
|
||||
for (const entry of entries) {
|
||||
if (!ENTRY_KINDS.includes(entry?.kind)) {
|
||||
throw new TypeError(`Unsupported journal entry kind: ${entry?.kind}`);
|
||||
}
|
||||
if (entry.kind === "resolution") {
|
||||
throw new TypeError("Plugins cannot append resolution entries directly");
|
||||
}
|
||||
if (role === "observer" && !["evidence", "warning"].includes(entry.kind)) {
|
||||
throw new TypeError("Observer plugins may append evidence and warnings only");
|
||||
}
|
||||
if (role === "advisor" && entry.kind !== "vote" && !["evidence", "warning"].includes(entry.kind)) {
|
||||
throw new TypeError("Advisor plugins may append evidence, warnings, and votes only");
|
||||
}
|
||||
}
|
||||
|
||||
return immutableClone({ entries });
|
||||
}
|
||||
|
||||
export function validateVerdict(verdict, facts) {
|
||||
if (verdict === undefined) return undefined;
|
||||
if (verdict === null || typeof verdict !== "object") {
|
||||
throw new TypeError("Trust verdict must be an object");
|
||||
}
|
||||
if (typeof verdict.trusted !== "boolean") {
|
||||
throw new TypeError("Trust verdict must contain Boolean trusted");
|
||||
}
|
||||
|
||||
const scope = verdict.scope ?? {};
|
||||
if (scope.hostname !== facts.hostname || scope.port !== facts.port) {
|
||||
throw new TypeError("Trust verdict scope does not match the connection");
|
||||
}
|
||||
|
||||
return immutableClone({
|
||||
trusted: verdict.trusted,
|
||||
scope: { hostname: scope.hostname, port: scope.port },
|
||||
reasonEntryIds: verdict.reasonEntryIds ?? [],
|
||||
expiresAt: verdict.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateVerdictAuthority(verdict, role) {
|
||||
if (verdict === undefined) return;
|
||||
if (role === "observer" || role === "advisor") {
|
||||
throw new TypeError(`${role} plugin cannot issue a terminal verdict`);
|
||||
}
|
||||
if (role === "veto-authority" && verdict.trusted) {
|
||||
throw new TypeError("Veto authority cannot issue a trusted verdict");
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value, name) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new TypeError(`${name} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireInteger(value, name) {
|
||||
if (!Number.isInteger(value)) throw new TypeError(`${name} must be an integer`);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
||||
import { DecisionJournal } from "./journal.js";
|
||||
import {
|
||||
createTlsFacts,
|
||||
validateContribution,
|
||||
validateManifest,
|
||||
validateVerdict,
|
||||
validateVerdictAuthority,
|
||||
} from "./protocol.js";
|
||||
|
||||
export class TrustRunner {
|
||||
constructor({ plugins = [], finalHandler, timeoutMs = 25 } = {}) {
|
||||
this.plugins = plugins.map(normalizePlugin);
|
||||
this.finalHandler = normalizeFinalHandler(
|
||||
finalHandler ?? createBuiltinFinalHandler(),
|
||||
);
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
async evaluate(inputFacts) {
|
||||
const facts = createTlsFacts(inputFacts);
|
||||
const journal = new DecisionJournal();
|
||||
|
||||
await this.#collectEvidence(facts, journal);
|
||||
let terminalVerdict;
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (!plugin.decide) continue;
|
||||
try {
|
||||
const candidate = await withTimeout(
|
||||
Promise.resolve(plugin.decide({ facts, journal: journal.snapshot() })),
|
||||
this.timeoutMs,
|
||||
);
|
||||
const verdict = validateVerdict(candidate, facts);
|
||||
if (!verdict) {
|
||||
journal.append(plugin.manifest, [{ kind: "abstention" }]);
|
||||
continue;
|
||||
}
|
||||
validateVerdictAuthority(verdict, plugin.manifest.role);
|
||||
|
||||
terminalVerdict = verdict;
|
||||
journal.append(plugin.manifest, [
|
||||
{
|
||||
kind: "resolution",
|
||||
code: verdict.trusted ? "trusted" : "not-trusted",
|
||||
data: verdict,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
} catch (error) {
|
||||
journal.append(plugin.manifest, [pluginError(error)]);
|
||||
}
|
||||
}
|
||||
|
||||
const sealedJournal = journal.seal();
|
||||
const finalVerdict = validateVerdict(
|
||||
await this.finalHandler.finalize({
|
||||
facts,
|
||||
journal: sealedJournal,
|
||||
terminalVerdict,
|
||||
}),
|
||||
facts,
|
||||
);
|
||||
|
||||
if (!finalVerdict) {
|
||||
throw new Error("Built-in final handler must return a trust verdict");
|
||||
}
|
||||
|
||||
return Object.freeze({ facts, journal: sealedJournal, verdict: finalVerdict });
|
||||
}
|
||||
|
||||
async #collectEvidence(facts, journal) {
|
||||
const collectors = this.plugins.map(async (plugin) => {
|
||||
if (!plugin.collectEvidence) return { plugin, contribution: { entries: [] } };
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
Promise.resolve(plugin.collectEvidence({ facts })),
|
||||
this.timeoutMs,
|
||||
);
|
||||
return {
|
||||
plugin,
|
||||
contribution: validateContribution(result, plugin.manifest.role),
|
||||
};
|
||||
} catch (error) {
|
||||
return { plugin, contribution: { entries: [pluginError(error)] } };
|
||||
}
|
||||
});
|
||||
|
||||
// Collect independently, then append in configured order for reproducibility.
|
||||
for (const { plugin, contribution } of await Promise.all(collectors)) {
|
||||
journal.append(plugin.manifest, contribution.entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlugin(plugin) {
|
||||
return Object.freeze({
|
||||
manifest: validateManifest(plugin.manifest),
|
||||
collectEvidence: plugin.collectEvidence?.bind(plugin),
|
||||
decide: plugin.decide?.bind(plugin),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFinalHandler(handler) {
|
||||
const normalized = normalizePlugin(handler);
|
||||
if (handler.manifest.id !== "org.browsec.builtin-final-handler") {
|
||||
throw new TypeError("Final handler must be Browsec's built-in handler");
|
||||
}
|
||||
if (typeof handler.finalize !== "function") {
|
||||
throw new TypeError("Built-in final handler must implement finalize");
|
||||
}
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
finalize: handler.finalize.bind(handler),
|
||||
});
|
||||
}
|
||||
|
||||
function pluginError(error) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: error?.name === "TimeoutError" ? "plugin-timeout" : "plugin-error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs) {
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const error = new Error(`Plugin exceeded ${timeoutMs} ms deadline`);
|
||||
error.name = "TimeoutError";
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createBuiltinFinalHandler,
|
||||
DecisionJournal,
|
||||
TrustRunner,
|
||||
} from "../src/index.js";
|
||||
import {
|
||||
unknownLocalAuthority,
|
||||
validPublicCertificate,
|
||||
} from "../fixtures/tls.js";
|
||||
|
||||
test("built-in handler rejects an unresolved certificate failure", async () => {
|
||||
const result = await new TrustRunner().evaluate(unknownLocalAuthority);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.equal(result.journal.entries.length, 0);
|
||||
});
|
||||
|
||||
test("built-in handler preserves an ordinary successful validation", async () => {
|
||||
const result = await new TrustRunner().evaluate(validPublicCertificate);
|
||||
assert.equal(result.verdict.trusted, true);
|
||||
});
|
||||
|
||||
test("a decision plugin can trust a narrowly scoped connection", async () => {
|
||||
const plugin = pluginWith({
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, true);
|
||||
assert.equal(result.journal.entries.at(-1).kind, "resolution");
|
||||
assert.equal(result.journal.entries.at(-1).pluginId, plugin.manifest.id);
|
||||
});
|
||||
|
||||
test("evidence collection is independent but appended in plugin order", async () => {
|
||||
const slowFirst = pluginWith({
|
||||
id: "test.first",
|
||||
async collectEvidence() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return { entries: [{ kind: "evidence", code: "first" }] };
|
||||
},
|
||||
});
|
||||
const fastSecond = pluginWith({
|
||||
id: "test.second",
|
||||
collectEvidence() {
|
||||
return { entries: [{ kind: "warning", code: "second" }] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [slowFirst, fastSecond] }).evaluate(
|
||||
validPublicCertificate,
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.journal.entries.map((entry) => entry.code),
|
||||
["first", "second"],
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin timeout is recorded and falls through safely", async () => {
|
||||
const plugin = pluginWith({
|
||||
async decide() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin], timeoutMs: 5 }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
|
||||
});
|
||||
|
||||
test("a verdict cannot escape the active hostname and port", async () => {
|
||||
const plugin = pluginWith({
|
||||
decide() {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: "different.test", port: 443 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /scope does not match/);
|
||||
});
|
||||
|
||||
test("observer and advisor plugins cannot issue terminal verdicts", async () => {
|
||||
for (const role of ["observer", "advisor"]) {
|
||||
const plugin = pluginWith({
|
||||
role,
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /cannot issue/);
|
||||
}
|
||||
});
|
||||
|
||||
test("veto authority can reject but cannot trust", async () => {
|
||||
const plugin = pluginWith({
|
||||
role: "veto-authority",
|
||||
decide({ facts }) {
|
||||
return {
|
||||
trusted: true,
|
||||
scope: { hostname: facts.hostname, port: facts.port },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
|
||||
unknownLocalAuthority,
|
||||
);
|
||||
assert.equal(result.verdict.trusted, false);
|
||||
assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/);
|
||||
});
|
||||
|
||||
test("journal snapshots and entries are immutable", () => {
|
||||
const journal = new DecisionJournal();
|
||||
journal.append(
|
||||
{ id: "test.plugin", name: "Test plugin" },
|
||||
[{ kind: "evidence", data: { nested: true } }],
|
||||
);
|
||||
const snapshot = journal.snapshot();
|
||||
|
||||
assert.throws(() => snapshot.entries.push({}), TypeError);
|
||||
assert.throws(() => {
|
||||
snapshot.entries[0].data.nested = false;
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test("only Browsec's immutable handler can occupy the final position", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new TrustRunner({
|
||||
finalHandler: {
|
||||
manifest: {
|
||||
id: "test.impostor",
|
||||
name: "Impostor",
|
||||
role: "decision-authority",
|
||||
},
|
||||
finalize() {},
|
||||
},
|
||||
}),
|
||||
/must be Browsec's built-in handler/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
new TrustRunner({ finalHandler: createBuiltinFinalHandler() }),
|
||||
);
|
||||
});
|
||||
|
||||
function pluginWith({
|
||||
id = "test.plugin",
|
||||
role = "decision-authority",
|
||||
collectEvidence,
|
||||
decide,
|
||||
}) {
|
||||
return {
|
||||
manifest: { id, name: id, role },
|
||||
collectEvidence,
|
||||
decide,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user