Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2c8cdd4
MILAB-5815: feat: proto update
rfiskov Mar 27, 2026
2cd0a79
MILAB-5815: autgen: changeset
rfiskov Mar 27, 2026
110a697
MILAB-5815: fix: fields required by updated protobuf types
rfiskov Mar 27, 2026
385217b
MILAB-5815: fix: format transaction.ts to pass CI formatter check
rfiskov Mar 27, 2026
828479a
MILAB-5815: fix: fields required by updated protobuf types
rfiskov Mar 27, 2026
b05b7cd
MILAB-5815: fix: format ll_transaction.test.ts to pass CI formatter c…
rfiskov Mar 27, 2026
8b1b471
Merge branch 'main' into MILAB-5815_proto_update
DenKoren Mar 31, 2026
d47af3a
MILAB-5815: feat: proto update
rfiskov Apr 6, 2026
de52353
MILAB-5815: feat: changeset message
rfiskov Apr 6, 2026
16bdf1e
MILAB-5815: feat: proto optional signature (temporary)
rfiskov Apr 6, 2026
9eb7a35
MILAB-5815: autogen: regenerate
rfiskov Apr 6, 2026
c01dc4b
MILAB-5815: ref: remove signature placeholders
rfiskov Apr 6, 2026
4e6f65f
MILAB-5825: feat: signature cache
rfiskov Apr 7, 2026
156422b
MILAB-5815: feat: merge conflicts
rfiskov Apr 7, 2026
b8abbbe
MILAB-5815: autgen: merge confilict
rfiskov Apr 8, 2026
c05891e
MILAB-5815: fix: test sign propogation
rfiskov Apr 8, 2026
9913614
MILAB-5815: feat: sigature for proxied api
rfiskov Apr 8, 2026
0702bdc
MILAB-5815: autogen: changeset
rfiskov Apr 8, 2026
8ac66a7
MILAB-5815: ref: SignedResourceId
rfiskov Apr 8, 2026
84698f3
MILAB-5815: feat: LRU cache
rfiskov Apr 8, 2026
392677f
MILAB-5815: feat: better types; ResourceSignature
rfiskov Apr 8, 2026
5322110
MILAB-5815: style: format; fix test
rfiskov Apr 8, 2026
1cd84e1
MILAB-5815: style: fix formatting in pl-tree accessors
rfiskov Apr 8, 2026
5aa69af
MILAB-5815: style: format linter
rfiskov Apr 8, 2026
8bd1835
MILAB-5815: fix: remove useless regex escapes in pl-drivers handle pa…
rfiskov Apr 9, 2026
0a2e14a
MILAB-5815: fix: prevent infinite retries when download is aborted bu…
rfiskov Apr 9, 2026
7a37723
MILAB-5815: fix: drop shared signatue cache [drafr]
rfiskov Apr 9, 2026
2660124
MILAB-5815: ref: resource id [drafr]
rfiskov Apr 9, 2026
6db30bb
MILAB-5815: ref: resource id (cleanup) [drafr]
rfiskov Apr 9, 2026
1a8c22c
MILAB-5815: fix: test bigint -> resourceId conversion
rfiskov Apr 9, 2026
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: 11 additions & 0 deletions .changeset/resource-signature-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@milaboratories/pl-client": minor
"@milaboratories/pl-drivers": minor
"@milaboratories/pl-tree": minor
---

Add resource signature propagation for server-side access control.

- `pl-client`: cross-transaction `SignatureCache`, automatic signature tracking in `PlTransaction` (store/retrieve signatures for all resource and field operations), `setDefaultColor` for color proof on resource creation, `PermissionDeniedError` error type
- `pl-drivers`: pass `resourceSignature` through proxied APIs (download, upload, logs, progress, ls), encode signatures in remote blob and log handles
- `pl-tree`: propagate `resourceSignature` in `ResourceInfo`, `ResourceSnapshot`, and `PlTreeResource` state
53 changes: 43 additions & 10 deletions lib/node/pl-client/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import { LLPlClient } from "./ll_client";
import type { AnyResourceRef } from "./transaction";
import { PlTransaction, toGlobalResourceId, TxCommitConflict } from "./transaction";
import { createHash } from "node:crypto";
import type { OptionalResourceId, ResourceId } from "./types";
import type { OptionalResourceId, ResourceId, ResourceSignature } from "./types";
import {
bigintToResourceId,
ensureResourceIdNotNull,
isNullResourceId,
NullResourceId,
toResourceSignature,
} from "./types";
import { ClientRoot } from "../helpers/pl";
import { isUnimplementedError } from "./errors";
import { isPermissionDenied, isUnimplementedError } from "./errors";
import type { MiLogger, RetryOptions } from "@milaboratories/ts-helpers";
import { assertNever, createRetryState, nextRetryStateOrError } from "@milaboratories/ts-helpers";
import type { PlDriver, PlDriverDefinition } from "./driver";
Expand All @@ -32,6 +33,7 @@ import { addStat, initialTxStat } from "./stat";
import type { WireConnection } from "./wire";
import { advisoryLock } from "./advisory_locks";
import { plAddressToConfig } from "./config";
import { SignatureCache } from "./signature_cache";

export type TxOps = PlCallOps & {
sync?: boolean;
Expand Down Expand Up @@ -96,6 +98,9 @@ export class PlClient {
/** Resource data cache, to minimize redundant data rereading from remote db */
private readonly resourceDataCache: LRUCache<ResourceId, ResourceDataCacheRecord>;

/** Cross-transaction signature cache */
private readonly _signatureCache = new SignatureCache();

private constructor(
configOrAddress: PlClientConfig | string,
auth: AuthOps,
Expand Down Expand Up @@ -209,6 +214,12 @@ export class PlClient {
return this._serverInfo!;
}

/** Shared signature cache, persists across transactions.
* Call clear() on auth errors to invalidate stale signatures. */
public get signatureCache(): SignatureCache {
return this._signatureCache;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 clear() on auth errors is documented but not implemented

The JSDoc says "Call clear() on auth errors to invalidate stale signatures", but there is no call site in PlClient (or the auth refresh path) that actually invokes this._signatureCache.clear(). Callers who rely on this cache through the public signatureCache getter would need to know to call it themselves. Either wire it up automatically in the auth-error handling path, or make the documentation more explicit that this is entirely the caller's responsibility.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/node/pl-client/src/core/client.ts
Line: 216-219

Comment:
**`clear()` on auth errors is documented but not implemented**

The JSDoc says _"Call clear() on auth errors to invalidate stale signatures"_, but there is no call site in `PlClient` (or the auth refresh path) that actually invokes `this._signatureCache.clear()`. Callers who rely on this cache through the public `signatureCache` getter would need to know to call it themselves. Either wire it up automatically in the auth-error handling path, or make the documentation more explicit that this is entirely the caller's responsibility.

How can I resolve this? If you propose a fix, please make it concise.

}

/** Discovers or creates the user's root resource.
* Tries ListUserResources RPC first (new backend), falls back to
* legacy named-resource lookup for older backends. */
Expand All @@ -232,11 +243,13 @@ export class PlClient {

// Try ListUserResources first (new backend, gRPC only)
let rootFromServer: ResourceId | undefined;
let rootSignature: ResourceSignature | undefined;
try {
const responses = await this._ll.listUserResources({ limit: 1 });
for (const msg of responses) {
if (msg.entry.oneofKind === "userRoot") {
rootFromServer = bigintToResourceId(msg.entry.userRoot.resourceId);
rootSignature = toResourceSignature(msg.entry.userRoot.resourceSignature);
break;
}
}
Expand All @@ -246,6 +259,12 @@ export class PlClient {
}

if (rootFromServer !== undefined) {
// Store root signature in cross-transaction cache so subsequent
// transactions can attach it to requests and use it as color proof.
if (rootSignature && rootSignature.length > 0) {
this._signatureCache.set(rootFromServer, rootSignature);
}

// New path: server created/returned the root
if (this.conf.alternativeRoot === undefined) {
this._clientRoot = rootFromServer;
Expand Down Expand Up @@ -341,14 +360,20 @@ export class PlClient {
// opening low-level tx
const llTx = this.ll.createTx(writable, ops);
// wrapping it into high-level tx (this also asynchronously sends initialization message)
const tx = new PlTransaction(
llTx,
name,
writable,
clientRoot,
this.finalPredicate,
this.resourceDataCache,
);
const tx = new PlTransaction(llTx, name, writable, clientRoot, {
finalPredicate: this.finalPredicate,
resourceDataCache: this.resourceDataCache,
signatureCache: this._signatureCache,
});

// Auto-set default color proof so that resource creation (write TXs)
// and name lookups (read TXs) carry the correct access color.
if (!isNullResourceId(clientRoot)) {
const rootSig = this._signatureCache.get(clientRoot);
if (rootSig) {
tx.setDefaultColor(rootSig);
}
}

let ok = false;
let result: T | undefined = undefined;
Expand All @@ -369,6 +394,14 @@ export class PlClient {
} else {
// collecting stat
this._txErrorStat = addStat(this._txErrorStat, tx.stat);
// Invalidate all cached signatures on permission denied.
// Targeted invalidation is impractical here because the failing
// resource id is not reliably available from the error. A full
// clear is safe: signatures are re-populated lazily from server
// responses in subsequent transactions.
if (isPermissionDenied(e)) {
this._signatureCache.clear();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a significant event, and clearing the cache seems to be not enough and on the contrary creates an irrecoverable state for the whole system. As this is a rare thing we can afford doing something that can interrupt user flow, like log-out form the server, but we must be sure that there is a clear recovery route from this situation. And we must make sure that this "reset" actually propagates everywhere and cleans all preserved signatures.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two potential problems here:

  1. Independent cache in pl-tree.

ML stores resource signatures along with resources info.
Say, we faced permissionDenied here and dropped in-client cache. What resource tree should do? How it would handle this?

  1. Cache globality.
    We already know for sure we will have different roots in different transactions. When we add project sharing - shared project would be its own 'root' in terms of signatures.
    pl client is instantiated once. It is then passed to MiddleLayer, which orchestrates resource trees.
    Resource tree is the temporal structure that is replaced/instantiated when switching from project to project, when loading projects list and so on.

Current tests in monorepo do not cover this scenario, as switching from project to project having the same client never was a problem, but it seems it becomes now, because global client-level cache of signatures does not sync with trees switched by ML when user does actions in UI.

}
throw e;
}
} finally {
Expand Down
23 changes: 23 additions & 0 deletions lib/node/pl-client/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export function isUnauthenticated(err: unknown, nested: boolean = false): boolea
return false;
}

export function isPermissionDenied(err: unknown, nested: boolean = false): boolean {
if (err === undefined || err === null) return false;

if (err instanceof PermissionDeniedError) return true;
if ((err as any).name === "RpcError" && (err as any).code === "PERMISSION_DENIED") return true;
if ((err as any).name === "RESTError" && (err as any).status.code === Code.PERMISSION_DENIED)
return true;
if ((err as any).cause !== undefined && !nested)
return isPermissionDenied((err as any).cause, true);
return false;
}

export function isTimeoutError(err: unknown, nested: boolean = false): boolean {
if (err === undefined || err === null) return false;

Expand Down Expand Up @@ -125,6 +137,13 @@ export class UnauthenticatedError extends Error {
}
}

export class PermissionDeniedError extends Error {
name = "PermissionDeniedError";
constructor(message: string) {
super("PermissionDenied: " + message);
}
}

export class DisconnectedError extends Error {
name = "DisconnectedError";
constructor(message: string) {
Expand All @@ -144,6 +163,10 @@ export function rethrowMeaningfulError(error: any, wrapIfUnknown: boolean = fals
if (error instanceof UnauthenticatedError) throw error;
throw new UnauthenticatedError(error.message);
}
if (isPermissionDenied(error)) {
if (error instanceof PermissionDeniedError) throw error;
throw new PermissionDeniedError(error.message);
}
if (isConnectionProblem(error)) {
if (error instanceof DisconnectedError) throw error;
throw new DisconnectedError(error.message);
Expand Down
40 changes: 34 additions & 6 deletions lib/node/pl-client/src/core/ll_transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { test, expect } from "vitest";

import { isTimeoutOrCancelError } from "./errors";
import { Aborted } from "@milaboratories/ts-helpers";
import type { LLPlClient } from "./ll_client";

/** Get root resource signature from ListUserResources for use as color proof in tests. */
async function getRootSignature(client: LLPlClient): Promise<Uint8Array> {
const responses = await client.listUserResources({ limit: 1 });
for (const msg of responses) {
if (msg.entry.oneofKind === "userRoot" && msg.entry.userRoot.resourceSignature) {
return msg.entry.userRoot.resourceSignature;
}
}
return new Uint8Array(0);
}

test("check successful transaction", async () => {
const client = await getTestLLClient();
Expand Down Expand Up @@ -80,6 +92,7 @@ test("check timeout error type (passive)", async () => {

test("check timeout error type (active)", async () => {
const client = await getTestLLClient();
const rootSig = await getRootSignature(client);
const tx = client.createTx(true, { timeout: 500 });

try {
Expand All @@ -96,6 +109,12 @@ test("check timeout error type (active)", async () => {
);
expect(openResponse.txOpen.tx?.isValid).toBeTruthy();

// Set default color so resource creation succeeds in strict mode
await tx.send(
{ oneofKind: "setDefaultColor", setDefaultColor: { colorProof: rootSig } },
false,
);

const rData = Uint8Array.from([
(Math.random() * 256) & 0xff,
(Math.random() * 256) & 0xff,
Expand All @@ -115,12 +134,13 @@ test("check timeout error type (active)", async () => {
type: { name: "TestValue", version: "1" },
data: rData,
errorIfExists: false,
colorProof: new Uint8Array(0),
},
},
false,
);
const id = (await createResponse).resourceCreateValue.resourceId;
const createResp = (await createResponse).resourceCreateValue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant await on already-resolved value

createResponse is obtained via const createResponse = await tx.send(...) (line 129), so it is already the resolved ServerMessageResponse value, not a Promise. The inner await createResponse on this line re-awaits a non-Promise, which works but is misleading. The same pattern appears in the "check is abort error (active)" test at line 213.

Suggested change
const createResp = (await createResponse).resourceCreateValue;
const createResp = createResponse.resourceCreateValue;
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/node/pl-client/src/core/ll_transaction.test.ts
Line: 141

Comment:
**Redundant `await` on already-resolved value**

`createResponse` is obtained via `const createResponse = await tx.send(...)` (line 129), so it is already the resolved `ServerMessageResponse` value, not a `Promise`. The inner `await createResponse` on this line re-awaits a non-Promise, which works but is misleading. The same pattern appears in the "check is abort error (active)" test at line 213.

```suggestion
   const createResp = createResponse.resourceCreateValue;
```

How can I resolve this? If you propose a fix, please make it concise.

const id = createResp.resourceId;
const resourceSignature = createResp.resourceSignature ?? new Uint8Array(0);
Comment on lines +141 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The await on createResponse is redundant because createResponse is already the result of an awaited promise (from line 129). You can also use destructuring to make this block more concise.

Suggested change
const createResp = (await createResponse).resourceCreateValue;
const id = createResp.resourceId;
const resourceSignature = createResp.resourceSignature ?? new Uint8Array(0);
const { resourceId: id, resourceSignature: rawSignature } = createResponse.resourceCreateValue;
const resourceSignature = rawSignature ?? new Uint8Array(0);


while (true) {
const vr = await tx.send(
Expand All @@ -129,7 +149,7 @@ test("check timeout error type (active)", async () => {
resourceGet: {
resourceId: id,
loadFields: false,
resourceSignature: new Uint8Array(0),
resourceSignature,
},
},
false,
Expand All @@ -144,6 +164,7 @@ test("check timeout error type (active)", async () => {

test("check is abort error (active)", async () => {
const client = await getTestLLClient();
const rootSig = await getRootSignature(client);
const tx = client.createTx(true, { abortSignal: AbortSignal.timeout(100) });

try {
Expand All @@ -160,6 +181,12 @@ test("check is abort error (active)", async () => {
);
expect(openResponse.txOpen.tx?.isValid).toBeTruthy();

// Set default color so resource creation succeeds in strict mode
await tx.send(
{ oneofKind: "setDefaultColor", setDefaultColor: { colorProof: rootSig } },
false,
);

const rData = Uint8Array.from([
Math.random() & 0xff,
Math.random() & 0xff,
Expand All @@ -179,12 +206,13 @@ test("check is abort error (active)", async () => {
type: { name: "TestValue", version: "1" },
data: rData,
errorIfExists: false,
colorProof: new Uint8Array(0),
},
},
false,
);
const id = (await createResponse).resourceCreateValue.resourceId;
const createResp = (await createResponse).resourceCreateValue;
const id = createResp.resourceId;
const resourceSignature = createResp.resourceSignature ?? new Uint8Array(0);
Comment on lines +213 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous comment, the await on createResponse is redundant here since createResponse is already an awaited promise (from line 201). This can be made more concise with destructuring.

Suggested change
const createResp = (await createResponse).resourceCreateValue;
const id = createResp.resourceId;
const resourceSignature = createResp.resourceSignature ?? new Uint8Array(0);
const { resourceId: id, resourceSignature: rawSignature } = createResponse.resourceCreateValue;
const resourceSignature = rawSignature ?? new Uint8Array(0);


while (true) {
const vr = await tx.send(
Expand All @@ -193,7 +221,7 @@ test("check is abort error (active)", async () => {
resourceGet: {
resourceId: id,
loadFields: false,
resourceSignature: new Uint8Array(0),
resourceSignature,
},
},
false,
Expand Down
44 changes: 44 additions & 0 deletions lib/node/pl-client/src/core/signature_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { LRUCache } from "lru-cache";
import type { ResourceId, ResourceSignature, SignedResourceId } from "./types";

const DEFAULT_MAX_ENTRIES = 100_000;

/** Cross-transaction cache for resource signatures.
* Keyed by ResourceId, stores opaque ResourceSignature bytes.
* Uses LRU eviction to prevent unbounded memory growth in long-running clients. */
export class SignatureCache {
private readonly store: LRUCache<ResourceId, ResourceSignature>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LRUCache is not a proper structure here and creates a fragile time bomb. All the users of the pl-client rely on this structure to preserve signatures for the resource id's that they store elsewhere, given the nature of the cache some signatures will be eventually evicted (and resource turnaround in real projects is pretty high) creating irrecoverable state for the whole system. We have a global resource id type and associated logic with explicit call in the transaction where user resolves the global resource IDs, expressing the intent to get something from the system that can be stored "persistently" to be later used for resource retrieval. We must rely on this mechanism and explicitly store signatures in those places where resource ids are persisted locally.


constructor(maxEntries: number = DEFAULT_MAX_ENTRIES) {
this.store = new LRUCache({ max: maxEntries });
}

set(id: ResourceId, sig: ResourceSignature): void {
this.store.set(id, sig);
}

get(id: ResourceId): ResourceSignature | undefined {
return this.store.get(id);
}

/** Return a SignedResourceId by looking up the cached signature. */
sign(id: ResourceId): SignedResourceId {
return { resourceId: id, resourceSignature: this.get(id) };
}

delete(id: ResourceId): boolean {
return this.store.delete(id);
}

clear(): void {
this.store.clear();
}

has(id: ResourceId): boolean {
return this.store.has(id);
}

get size(): number {
return this.store.size;
}
}
Loading
Loading