Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
92 changes: 92 additions & 0 deletions .changeset/cuddly-dancers-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
"@milaboratories/pl-client": major
---

Sync pl proto API: resource signing, access control, locks, auth, field renames.

**Breaking changes:**

Proto field renames:
- `Resource.id` → `Resource.resource_id`
- `ResourceAPI.Remove.Request.id` → `ResourceAPI.Remove.Request.resource_id`
- `FieldAPI.SetError.Request.err_resource_id` → `FieldAPI.SetError.Request.error_resource_id`

Proto field removals:
- `MaintenanceAPI.Ping.Response.server_info` removed (field 3)

Proto deprecations:
- `CacheAPI.DeleteExpiredRecords` replaced with `Util.Deprecated` placeholder

Lease endpoint URL changes:
- `/v1/locks/lease` → `/v1/locks/lease/create`
- `PUT /v1/locks/lease` → `POST /v1/locks/lease/update`
- `DELETE /v1/locks/lease` → `POST /v1/locks/lease/release`

**New proto fields — resource signing:**

Core messages:
- `Resource.resource_signature` (bytes) — opaque signature for resource ID
- `Field.value_signature` (bytes) — signature for field value resource, inheriting parent color
- `Field.error_signature` (bytes) — signature for error resource, inheriting parent color
- `FieldRef.resource_signature` (bytes) — signature for the referenced resource

Transaction operations:
- `TxAPI.SetDefaultColor` — set default color for resource creation via `color_proof`

Resource creation — `color_proof` added to:
- `CreateStruct.Request`, `CreateEphemeral.Request`, `CreateValue.Request`, `CreateSingleton.Request`

Resource creation — `resource_signature` added to responses:
- `CreateStruct`, `CreateEphemeral`, `CreateValue`, `CreateSingleton`, `CreateRoot`, `GetValueID`

Resource access — `resource_signature` added to requests:
- `Remove`, `Get`, `LockInputs`, `LockOutputs`, `Exists`, `SetError` (+ `error_resource_signature`), `Tree`, `TreeSize`, `Name.Set`

Resource access — `resource_signature` added to responses:
- `Name.Get`

Field operations — `resource_signature` added to:
- `FieldAPI.List.Request`, `FieldAPI.SetError.Request` (`error_resource_signature`)

KV operations — `resource_signature` added to all `ResourceKVAPI.*.Request`:
- `Set`, `Get`, `GetIfExists`, `Delete`, `SetFlag`, `GetFlag`, `List`

Lease operations — `resource_signature` added to:
- `Lease.Create.Request`, `Lease.Update.Request`, `Lease.Release.Request`

**New API — access control:**

RPCs:
- `GrantAccess` — grant resource access to another user
- `RevokeGrant` — revoke previously granted access
- `ListGrants` — server-side streaming, list grants for a resource
- `ListUserResources` — server-side streaming, user root + shared resources with pagination

Messages:
- `AuthAPI.Grant` — grant record (user, resource_id, permissions, granted_by, granted_at)
- `AuthAPI.Grant.Permissions` — access bitmask (writable)
- `AuthAPI.ListUserResources.UserRoot` — signed user root
- `AuthAPI.ListUserResources.SharedResource` — signed shared resource with type and permissions

**New API — auth:**

- `AuthAPI.GetJWTToken.Role` enum — `ROLE_UNSPECIFIED`, `USER`, `CONTROLLER`, `WORKFLOW`
- `AuthAPI.GetJWTToken.Request.requested_role` — request JWT with specific role
- `AuthAPI.GetJWTToken.Response.session_id` — 128-bit session ID

**New API — locks:**

- `LocksAPI.LockFieldValues` — optimistic locking on resolved field values

**New API — schema:**

- `ResourceSchema.AccessFlags` — per-type access restrictions for non-controller roles (create_resource, read_fields, write_fields, read_kv, write_kv, per-field-type overrides via read_by_field_type/write_by_field_type maps)
- `ResourceSchema.free_inputs` / `free_outputs` — skip automatic locking on creation

**New API — notifications:**

- `Notification.Events.resource_recovered` — new event type

**New utility:**

- `Util.Deprecated` — empty message for deprecated oneOf slots
28 changes: 28 additions & 0 deletions lib/node/pl-client/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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 +97,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 +213,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 +242,13 @@ export class PlClient {

// Try ListUserResources first (new backend, gRPC only)
let rootFromServer: ResourceId | undefined;
let rootSignature: Uint8Array | 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 = msg.entry.userRoot.resourceSignature;
break;
}
}
Expand All @@ -246,6 +258,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 @@ -348,8 +366,18 @@ export class PlClient {
clientRoot,
this.finalPredicate,
this.resourceDataCache,
this._signatureCache,
);

// Auto-set default color proof for write transactions so that
// resource creation carries the correct access color.
if (writable && !isNullResourceId(clientRoot)) {
const rootSig = this._signatureCache.get(clientRoot);
if (rootSig) {
tx.setDefaultColor(rootSig);
}
}

let ok = false;
let result: T | undefined = undefined;
let txId;
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
29 changes: 29 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,29 @@
/** Cross-transaction cache for resource signatures.
* Keyed by resource ID (bigint), stores opaque signature bytes (Uint8Array). */
export class SignatureCache {
private readonly store = new Map<bigint, Uint8Array>();

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 Unbounded cache with no eviction policy

SignatureCache uses a plain Map keyed by resource ID with no size cap or TTL. In a long-running process where many resources are created or observed, the store will grow without bound. Consider capping it with an LRUCache (already a dependency via lru-cache) or adding a maxSize option, consistent with the existing resourceDataCache in PlClient.

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

Comment:
**Unbounded cache with no eviction policy**

`SignatureCache` uses a plain `Map` keyed by resource ID with no size cap or TTL. In a long-running process where many resources are created or observed, the store will grow without bound. Consider capping it with an `LRUCache` (already a dependency via `lru-cache`) or adding a `maxSize` option, consistent with the existing `resourceDataCache` in `PlClient`.

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


set(id: bigint, sig: Uint8Array): void {
this.store.set(id, sig);
}

get(id: bigint): Uint8Array | undefined {
return this.store.get(id);
}

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

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

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

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