Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class DecisionEngine {
this.idGen = opts.idGen ?? randomUUID;
}

async evaluate(intent: TxIntent, policy: Policy): Promise<Decision> {
async evaluate(intent: TxIntent, policy: Policy, clientId?: string): Promise<Decision> {
const reasons: string[] = [];
const rulesMatched: string[] = [];
let verdict: Verdict = "ALLOW";
Expand All @@ -99,7 +99,7 @@ export class DecisionEngine {
timestamp: this.now(),
};
await this.handleRemediation(earlyDecision, policy);
return this.persist(earlyDecision);
return this.persist(earlyDecision, clientId);
}

const valueWei = tryBigInt(intent.value);
Expand All @@ -125,6 +125,7 @@ export class DecisionEngine {
const recent = await this.store.listDecisions({
owner: policy.owner,
from: since,
...(clientId !== undefined && { clientId }),
});
const usedWei = recent
.filter((d) => d.verdict !== "BLOCK")
Expand Down Expand Up @@ -209,7 +210,7 @@ export class DecisionEngine {
await this.handleRemediation(decision, policy);
}

return this.persist(decision);
return this.persist(decision, clientId);
}

private async runSimulator(intent: TxIntent): Promise<SimulationResult | undefined> {
Expand All @@ -227,8 +228,8 @@ export class DecisionEngine {
}
}

private async persist(decision: Decision): Promise<Decision> {
await this.store.appendDecision(decision);
private async persist(decision: Decision, clientId?: string): Promise<Decision> {
await this.store.appendDecision(decision, clientId);
return decision;
}

Expand Down
18 changes: 9 additions & 9 deletions src/core/policyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class PolicyService {
private readonly idGen: () => string = randomUUID,
) {}

async create(input: unknown): Promise<Policy> {
async create(input: unknown, clientId?: string): Promise<Policy> {
const parsed = policyInputSchema.parse(input);
const policy: Policy = {
id: this.idGen(),
Expand All @@ -20,12 +20,12 @@ export class PolicyService {
version: 1,
updatedAt: this.now(),
};
await this.store.putPolicy(policy);
await this.store.putPolicy(policy, clientId);
return policy;
}

async update(id: string, input: PolicyInput): Promise<Policy> {
const existing = await this.store.getPolicy(id);
async update(id: string, input: PolicyInput, clientId?: string): Promise<Policy> {
const existing = await this.store.getPolicy(id, clientId);
if (!existing) throw new Error(`Policy ${id} not found`);
const policy: Policy = {
...existing,
Expand All @@ -35,15 +35,15 @@ export class PolicyService {
version: existing.version + 1,
updatedAt: this.now(),
};
await this.store.putPolicy(policy);
await this.store.putPolicy(policy, clientId);
return policy;
}

get(id: string): Promise<Policy | null> {
return this.store.getPolicy(id);
get(id: string, clientId?: string): Promise<Policy | null> {
return this.store.getPolicy(id, clientId);
}

list(owner?: Policy["owner"]): Promise<Policy[]> {
return this.store.listPolicies(owner);
list(owner?: Policy["owner"], clientId?: string): Promise<Policy[]> {
return this.store.listPolicies({ ...(owner !== undefined && { owner }), ...(clientId !== undefined && { clientId }) });
}
}
71 changes: 53 additions & 18 deletions src/memory/memoryStore.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,74 @@
import type { Decision, Policy } from "../core/types.js";
import type { Store } from "./store.js";

interface TaggedPolicy {
policy: Policy;
clientId: string | null;
}

interface TaggedDecision {
decision: Decision;
clientId: string | null;
}

/**
* In-memory `Store` with per-browser isolation. Every row is tagged with the
* `clientId` that wrote it (null when the writer didn't supply one — typical
* for the demo CLI / curl). Reads scope to the caller's clientId when one is
* supplied; an `undefined` clientId returns everything (admin / debug).
*/
export class InMemoryStore implements Store {
private policies = new Map<string, Policy>();
private decisions: Decision[] = [];
private policies = new Map<string, TaggedPolicy>();
private decisions: TaggedDecision[] = [];

async putPolicy(policy: Policy): Promise<void> {
this.policies.set(policy.id, policy);
async putPolicy(policy: Policy, clientId?: string): Promise<void> {
this.policies.set(policy.id, { policy, clientId: clientId ?? null });
}

async getPolicy(id: string): Promise<Policy | null> {
return this.policies.get(id) ?? null;
async getPolicy(id: string, clientId?: string): Promise<Policy | null> {
const row = this.policies.get(id);
if (!row) return null;
if (clientId !== undefined && row.clientId !== clientId) return null;
return row.policy;
}

async listPolicies(owner?: Policy["owner"]): Promise<Policy[]> {
async listPolicies(filter: {
owner?: Policy["owner"];
clientId?: string;
} = {}): Promise<Policy[]> {
const all = [...this.policies.values()];
return owner ? all.filter((p) => p.owner.toLowerCase() === owner.toLowerCase()) : all;
return all
.filter((row) => {
if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false;
if (filter.owner && row.policy.owner.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
})
.map((row) => row.policy);
}

async appendDecision(decision: Decision): Promise<void> {
this.decisions.push(decision);
async appendDecision(decision: Decision, clientId?: string): Promise<void> {
this.decisions.push({ decision, clientId: clientId ?? null });
}

async listDecisions(filter: {
owner?: Policy["owner"];
from?: number;
to?: number;
clientId?: string;
}): Promise<Decision[]> {
return this.decisions.filter((d) => {
if (filter.from !== undefined && d.timestamp < filter.from) return false;
if (filter.to !== undefined && d.timestamp > filter.to) return false;
if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
});
return this.decisions
.filter((row) => {
if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false;
const d = row.decision;
if (filter.from !== undefined && d.timestamp < filter.from) return false;
if (filter.to !== undefined && d.timestamp > filter.to) return false;
if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
})
.map((row) => row.decision);
}
}
31 changes: 27 additions & 4 deletions src/memory/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,38 @@ export interface AnchorRecord {
txHash: string;
}

/**
* Per-browser session isolation contract.
*
* Every read + write accepts an optional `clientId`. The frontend generates a
* stable UUID per browser (stored in localStorage) and sends it as the
* `X-Client-Id` HTTP header on every API call; the Fastify route reads the
* header and threads the value down to the Store. Implementations:
*
* - On WRITE, tag the row with the supplied clientId. `undefined` ->
* untagged (system/admin/CLI rows).
* - On READ, when a clientId is provided, only return rows tagged with
* the same clientId. `undefined` -> return everything (admin / curl /
* test fixtures).
*
* The contract NEVER leaks the existence of a row owned by a different
* client: `getPolicy("foreign-id", "my-client")` returns `null`, identical
* to a genuinely missing id. This is the property `tests/clientIsolation`
* locks down.
*/
export interface Store {
putPolicy(policy: Policy): Promise<void>;
getPolicy(id: string): Promise<Policy | null>;
listPolicies(owner?: Policy["owner"]): Promise<Policy[]>;
appendDecision(decision: Decision): Promise<void>;
putPolicy(policy: Policy, clientId?: string): Promise<void>;
getPolicy(id: string, clientId?: string): Promise<Policy | null>;
listPolicies(filter?: {
owner?: Policy["owner"];
clientId?: string;
}): Promise<Policy[]>;
appendDecision(decision: Decision, clientId?: string): Promise<void>;
listDecisions(filter: {
owner?: Policy["owner"];
from?: number;
to?: number;
clientId?: string;
}): Promise<Decision[]>;
getAnchor?(id: string): AnchorRecord | undefined;
}
105 changes: 66 additions & 39 deletions src/memory/zeroGStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,34 @@ export interface ZeroGStoreOptions {
logger?: Pick<Console, "log" | "warn">;
}

interface TaggedPolicy {
policy: Policy;
clientId: string | null;
}

interface TaggedDecision {
decision: Decision;
clientId: string | null;
}

export class ZeroGStore implements Store {
private readonly indexer: IndexerLike;
private readonly signer: ethers.Signer;
private readonly rpcUrl: string;
private readonly logger: Pick<Console, "log" | "warn">;

private policies = new Map<string, Policy>();
private decisions: Decision[] = [];
private policies = new Map<string, TaggedPolicy>();
private decisions: TaggedDecision[] = [];
private anchors = new Map<string, AnchorRecord>();

/**
* Tracks every in-flight 0G upload by row id so tests + the API admin
* route can `await` for the anchor to land. In production nothing awaits
* this — `putPolicy`/`appendDecision` return immediately after the local
* write so the request hot path stays under 50ms instead of 5-30s.
*/
private pending = new Map<string, Promise<void>>();

constructor(opts: ZeroGStoreOptions) {
this.rpcUrl = opts.rpcUrl;
this.logger = opts.logger ?? console;
Expand All @@ -59,35 +77,63 @@ export class ZeroGStore implements Store {
}
}

/**
* Tracks every in-flight 0G upload by row id so tests + the API admin
* route can `await` for the anchor to land. In production nothing awaits
* this — `putPolicy`/`appendDecision` return immediately after the local
* write so the request hot path stays under 50ms instead of 5-30s.
*/
private pending = new Map<string, Promise<void>>();

async putPolicy(policy: Policy): Promise<void> {
this.policies.set(policy.id, policy);
async putPolicy(policy: Policy, clientId?: string): Promise<void> {
this.policies.set(policy.id, { policy, clientId: clientId ?? null });
this.scheduleAnchor(policy.id, `policy:${policy.id}`, JSON.stringify(policy));
}

async getPolicy(id: string): Promise<Policy | null> {
return this.policies.get(id) ?? null;
async getPolicy(id: string, clientId?: string): Promise<Policy | null> {
const row = this.policies.get(id);
if (!row) return null;
if (clientId !== undefined && row.clientId !== clientId) return null;
return row.policy;
}

async listPolicies(owner?: Policy["owner"]): Promise<Policy[]> {
async listPolicies(filter: {
owner?: Policy["owner"];
clientId?: string;
} = {}): Promise<Policy[]> {
const all = [...this.policies.values()];
return owner
? all.filter((p) => p.owner.toLowerCase() === owner.toLowerCase())
: all;
return all
.filter((row) => {
if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false;
if (filter.owner && row.policy.owner.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
})
.map((row) => row.policy);
}

async appendDecision(decision: Decision): Promise<void> {
this.decisions.push(decision);
async appendDecision(decision: Decision, clientId?: string): Promise<void> {
this.decisions.push({ decision, clientId: clientId ?? null });
this.scheduleAnchor(decision.id, `decision:${decision.id}`, JSON.stringify(decision));
}

async listDecisions(filter: {
owner?: Policy["owner"];
from?: number;
to?: number;
clientId?: string;
}): Promise<Decision[]> {
return this.decisions
.filter((row) => {
if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false;
const d = row.decision;
if (filter.from !== undefined && d.timestamp < filter.from) return false;
if (filter.to !== undefined && d.timestamp > filter.to) return false;
if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
})
.map((row) => row.decision);
}

getAnchor(id: string): AnchorRecord | undefined {
return this.anchors.get(id);
}

/**
* Awaits the background anchor upload for a single row. Used by tests so
* they can assert anchor presence after the upload settles, without
Expand Down Expand Up @@ -117,25 +163,6 @@ export class ZeroGStore implements Store {
this.pending.set(rowId, p);
}

async listDecisions(filter: {
owner?: Policy["owner"];
from?: number;
to?: number;
}): Promise<Decision[]> {
return this.decisions.filter((d) => {
if (filter.from !== undefined && d.timestamp < filter.from) return false;
if (filter.to !== undefined && d.timestamp > filter.to) return false;
if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) {
return false;
}
return true;
});
}

getAnchor(id: string): AnchorRecord | undefined {
return this.anchors.get(id);
}

private async tryAnchor(label: string, json: string): Promise<AnchorRecord | null> {
const bytes = new TextEncoder().encode(json);
const file = new MemData(bytes);
Expand Down
Loading
Loading