Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 23 additions & 12 deletions lib/node/pl-client/src/core/client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { AuthOps, PlClientConfig, PlConnectionStatusListener, wireProtocol } from "./config";
import type { PlCallOps } from "./ll_client";
import { LLPlClient } from "./ll_client";
import type { AnyResourceRef } from "./transaction";
import type { AnyResourceRef, SignatureResolver } from "./transaction";
import { PlTransaction, toGlobalResourceId, TxCommitConflict } from "./transaction";
import { createHash } from "node:crypto";
import type { OptionalResourceId, ResourceId } from "./types";
import type { GlobalResourceId, OptionalResourceId, ResourceId } from "./types";
import {
bigintToResourceId,
ensureResourceIdNotNull,
isNullResourceId,
NullResourceId,
toResourceSignature,
} from "./types";
import { ClientRoot } from "../helpers/pl";
import { isUnimplementedError } from "./errors";
Expand Down Expand Up @@ -38,6 +39,7 @@ export type TxOps = PlCallOps & {
retryOptions?: RetryOptions;
name?: string;
lockId?: string;
signatureResolver?: SignatureResolver;
};

const defaultTxOps = {
Expand Down Expand Up @@ -94,7 +96,7 @@ export class PlClient {
public readonly finalPredicate: FinalResourceDataPredicate;

/** Resource data cache, to minimize redundant data rereading from remote db */
private readonly resourceDataCache: LRUCache<ResourceId, ResourceDataCacheRecord>;
private readonly resourceDataCache: LRUCache<GlobalResourceId, ResourceDataCacheRecord>;

private constructor(
configOrAddress: PlClientConfig | string,
Expand Down Expand Up @@ -236,7 +238,10 @@ export class PlClient {
const responses = await this._ll.listUserResources({ limit: 1 });
for (const msg of responses) {
if (msg.entry.oneofKind === "userRoot") {
rootFromServer = bigintToResourceId(msg.entry.userRoot.resourceId);
rootFromServer = bigintToResourceId(
msg.entry.userRoot.resourceId,
toResourceSignature(msg.entry.userRoot.resourceSignature),
);
break;
}
}
Expand Down Expand Up @@ -268,6 +273,9 @@ export class PlClient {

return await altRoot.globalId;
},
rootFromServer.signature !== undefined
? { signatureResolver: (id) => (id.id === rootFromServer!.id ? rootFromServer!.signature : undefined) }
: undefined,
);
}
} else {
Expand Down Expand Up @@ -341,14 +349,17 @@ 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,
signatureResolver: ops?.signatureResolver,
});

// Auto-set default color proof so that resource creation (write TXs)
// and name lookups (read TXs) carry the correct access color.
if (!isNullResourceId(clientRoot) && clientRoot.signature && writable) {
tx.setDefaultColor(clientRoot.signature);
}

let ok = false;
let result: T | undefined = undefined;
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
5 changes: 2 additions & 3 deletions lib/node/pl-client/src/core/ll_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ test("test https call via proxy", async () => {
test("list user resources returns user root", async () => {
const client = await getTestLLClient();
const responses = await client.listUserResources({ limit: 1 });
expect(responses.length).toBe(1);
const msg = responses[0];
expect(msg.entry.oneofKind).toBe("userRoot");
const userRoots = responses.filter((r) => r.entry.oneofKind === "userRoot");
expect(userRoots.length).toBe(1);
});
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
Loading