Skip to content

Commit 504a380

Browse files
committed
[MILAB-6225]: Add federative envelope handling to Ls API
- Extend Ls API to carry additionalInfo envelope for federative storages - Switch list results to a file-stats variant and propagate envelopes - Pass envelope through to index handles when present - Update REST/GRPC schemas to include additionalInfo - Add tests to verify envelope threading and absence when empty
1 parent 623e308 commit 504a380

12 files changed

Lines changed: 274 additions & 17 deletions

File tree

.changeset/social-bars-beam.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@milaboratories/pl-middle-layer": minor
3+
"@milaboratories/pl-drivers": minor
4+
---
5+
6+
support file link signing

lib/node/pl-drivers/proto/shared/lsapi/openapi.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ components:
5959
isDir:
6060
type: boolean
6161
description: is_dir is true for an item that can have subitems.
62+
additionalInfo:
63+
type: object
64+
additionalProperties:
65+
type: string
66+
description: |-
67+
additional_info carries the signed identity envelope for federative storages.
68+
KV schema: v=1, path, uid, sid, role, exp, kid, signed, sig.
69+
Empty for non-federative storages. Verifiers MUST ignore unknown keys.
6270
fullName:
6371
type: string
6472
description: |-

lib/node/pl-drivers/proto/shared/lsapi/protocol.proto

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ message LsAPI {
3434
// is_dir is true for an item that can have subitems.
3535
bool is_dir = 3;
3636

37+
// additional_info carries the signed identity envelope for federative storages.
38+
// KV schema is authoritative in pl/util/storage/v4sign/envelope.go:
39+
// v=1, path, uid, sid (optional), role (optional), exp (unix-sec decimal), kid,
40+
// signed=path,uid,sid,role,exp,kid, sig (base64url HMAC-SHA256).
41+
// Empty for non-federative storages. Verifiers MUST ignore unknown keys.
42+
map<string, string> additional_info = 8;
43+
3744
// full_name is the full name of the item, relative to the storage root.
3845
// It is <directory> + <name>.
3946
// The <delimiter>, used in names, is storage-specific and is NOT guaranteed to be '/'.
@@ -66,7 +73,7 @@ message LsAPI {
6673
// Location to list, relative to the storage root. Only items that have <full_name> starting
6774
// with <location> are included in the list response.
6875
string location = 2;
69-
76+
7077
// // limit amount of items returned by server in single response.
7178
// // The default and maximum limit may differ for different storage types.
7279
// // If the storage has its own restrictions on <limit> value (<storage limit>),

lib/node/pl-drivers/src/clients/ls_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class ClientLs {
6868
name: item.name,
6969
size: BigInt(item.size),
7070
isDir: item.isDir,
71+
additionalInfo: item.additionalInfo ?? {},
7172
fullName: item.fullName,
7273
directory: item.directory,
7374
lastModified: parseTimestamp(item.lastModified),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from "vitest";
2+
import { createIndexImportHandle, parseIndexHandle } from "./ls_remote_import_handle";
3+
4+
// Golden URL for the no-envelope case — guards against accidental key injection or ordering drift.
5+
// Value: index://index/<url-encoded JSON of {storageId:"s3",path:"x/y"}>
6+
const GOLDEN_NO_ENVELOPE = `index://index/${encodeURIComponent(JSON.stringify({ storageId: "s3", path: "x/y" }))}`;
7+
8+
describe("createIndexImportHandle", () => {
9+
test("round-trips additionalInfo envelope", () => {
10+
const envelope = { uid: "u", sid: "s", sig: "abc123", exp: "9999999999", kid: "k", v: "1" };
11+
const handle = createIndexImportHandle("s3", "x/y", envelope);
12+
const parsed = parseIndexHandle(handle);
13+
expect(parsed.storageId).toBe("s3");
14+
expect(parsed.path).toBe("x/y");
15+
expect(parsed.additionalInfo).toEqual(envelope);
16+
});
17+
18+
test("no-arg case is byte-identical to golden (regression guard)", () => {
19+
const handle = createIndexImportHandle("s3", "x/y");
20+
expect(handle).toBe(GOLDEN_NO_ENVELOPE);
21+
});
22+
23+
test("empty map is pruned — byte-identical to no-arg golden", () => {
24+
const handle = createIndexImportHandle("s3", "x/y", {});
25+
expect(handle).toBe(GOLDEN_NO_ENVELOPE);
26+
});
27+
28+
test("absent envelope: decoded handle has no additionalInfo key", () => {
29+
const handle = createIndexImportHandle("s3", "x/y");
30+
const parsed = parseIndexHandle(handle);
31+
expect(parsed.additionalInfo).toBeUndefined();
32+
// Confirm the key is truly absent from decoded JSON (not just undefined)
33+
expect(Object.prototype.hasOwnProperty.call(parsed, "additionalInfo")).toBe(false);
34+
});
35+
36+
test("parseIndexHandle accepts old handle without additionalInfo", () => {
37+
// Simulate a handle that was created before this change (no additionalInfo key in JSON)
38+
const oldJson = JSON.stringify({ storageId: "legacy", path: "a/b" });
39+
const oldHandle = `index://index/${encodeURIComponent(oldJson)}` as import("@milaboratories/pl-model-common").ImportFileHandleIndex;
40+
const parsed = parseIndexHandle(oldHandle);
41+
expect(parsed.storageId).toBe("legacy");
42+
expect(parsed.path).toBe("a/b");
43+
expect(parsed.additionalInfo).toBeUndefined();
44+
});
45+
});

lib/node/pl-drivers/src/drivers/helpers/ls_remote_import_handle.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import { ImportFileHandleIndexData, ImportFileHandleUploadData } from "../types"
55
export function createIndexImportHandle(
66
storageId: string,
77
path: string,
8+
additionalInfo?: Record<string, string>,
89
): sdk.ImportFileHandleIndex {
910
const data: ImportFileHandleIndexData = {
1011
storageId: storageId,
1112
path: path,
1213
};
1314

15+
// Only embed the envelope when non-empty; preserves byte-identical URL for non-federative storages.
16+
if (additionalInfo && Object.keys(additionalInfo).length > 0) {
17+
data.additionalInfo = additionalInfo;
18+
}
19+
1420
return `index://index/${encodeURIComponent(JSON.stringify(data))}`;
1521
}
1622

lib/node/pl-drivers/src/drivers/ls.test.ts

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { ConsoleLoggerAdapter, HmacSha256Signer } from "@milaboratories/ts-helpers";
2-
import { LsDriver } from "./ls";
2+
import { LsDriver, type LsEntryWithFileStats } from "./ls";
33
import { TestHelpers } from "@milaboratories/pl-client";
44
import * as path from "node:path";
5-
import { test, expect } from "vitest";
5+
import { test, expect, describe } from "vitest";
66
import { isImportFileHandleIndex, isImportFileHandleUpload } from "@milaboratories/pl-model-common";
7+
import type { StorageHandle } from "@milaboratories/pl-model-common";
78
import * as env from "../test_env";
9+
import { parseIndexHandle } from "./helpers/ls_remote_import_handle";
10+
import { createRemoteStorageHandle } from "./helpers/ls_storage_entry";
811

912
const assetsPath = path.resolve("../../../assets");
1013

@@ -141,3 +144,135 @@ test("should ok when get file using local dialog, and read its content", async (
141144
expect(multiResult.files![0]).toStrictEqual(result.file);
142145
});
143146
});
147+
148+
// Unit tests: verify that LsDriver.listFiles and listRemoteFilesWithFileStats correctly
149+
// thread additionalInfo from gRPC list items into the index:// handle.
150+
describe("LsDriver additionalInfo threading", () => {
151+
const envelope = { uid: "u1", sid: "s1", sig: "sigval", exp: "9999999999", kid: "k1", v: "1" };
152+
153+
const storageInfo = {
154+
storageId: "test-storage",
155+
storageName: "Test Storage",
156+
resourceId: "res-id" as any,
157+
resourceType: { name: "LS/test-storage", version: "1" },
158+
};
159+
160+
// Builds a minimal LsDriver instance with an injected mock lsClient via private-constructor bypass.
161+
function makeMockDriver(listResponse: { items: any[]; delimiter: string }): LsDriver {
162+
const mockLsClient = {
163+
list: async () => listResponse,
164+
close: () => {},
165+
};
166+
const mockUserResources = {
167+
getDataLibraries: async () => new Map([[storageInfo.storageId, storageInfo]]),
168+
};
169+
const signer = new HmacSha256Signer("test");
170+
// Bypass private constructor for unit testing only.
171+
return new (LsDriver as any)(
172+
new ConsoleLoggerAdapter(),
173+
mockLsClient,
174+
mockUserResources,
175+
signer,
176+
new Map(),
177+
new Map(),
178+
() => Promise.resolve(undefined),
179+
) as LsDriver;
180+
}
181+
182+
function makeRemoteHandle(): StorageHandle {
183+
return createRemoteStorageHandle(storageInfo) as StorageHandle;
184+
}
185+
186+
test("listFiles: handle carries additionalInfo envelope from gRPC item", async () => {
187+
const driver = makeMockDriver({
188+
delimiter: "/",
189+
items: [
190+
{
191+
name: "file.txt",
192+
size: 100n,
193+
isDir: false,
194+
additionalInfo: envelope,
195+
fullName: "dir/file.txt",
196+
directory: "dir/",
197+
version: "v1",
198+
},
199+
],
200+
});
201+
202+
const result = await driver.listFiles(makeRemoteHandle(), "dir/");
203+
expect(result.entries).toHaveLength(1);
204+
205+
const parsed = parseIndexHandle(result.entries[0].handle as any);
206+
expect(parsed.additionalInfo).toEqual(envelope);
207+
});
208+
209+
test("listFiles: handle has no additionalInfo when item has empty envelope", async () => {
210+
const driver = makeMockDriver({
211+
delimiter: "/",
212+
items: [
213+
{
214+
name: "plain.txt",
215+
size: 50n,
216+
isDir: false,
217+
additionalInfo: {},
218+
fullName: "plain.txt",
219+
directory: "",
220+
version: "v1",
221+
},
222+
],
223+
});
224+
225+
const result = await driver.listFiles(makeRemoteHandle(), "");
226+
expect(result.entries).toHaveLength(1);
227+
228+
const parsed = parseIndexHandle(result.entries[0].handle as any);
229+
expect(parsed.additionalInfo).toBeUndefined();
230+
});
231+
232+
test("listRemoteFilesWithFileStats: handle carries additionalInfo envelope", async () => {
233+
const driver = makeMockDriver({
234+
delimiter: "/",
235+
items: [
236+
{
237+
name: "data.csv",
238+
size: 200n,
239+
isDir: false,
240+
additionalInfo: envelope,
241+
fullName: "data.csv",
242+
directory: "",
243+
version: "v2",
244+
},
245+
],
246+
});
247+
248+
const result = await driver.listRemoteFilesWithFileStats(makeRemoteHandle(), "");
249+
expect(result.entries).toHaveLength(1);
250+
expect((result.entries[0] as LsEntryWithFileStats).size).toBe(200);
251+
252+
const parsed = parseIndexHandle(result.entries[0].handle as any);
253+
expect(parsed.additionalInfo).toEqual(envelope);
254+
});
255+
256+
test("listRemoteFilesWithFileStats: no additionalInfo when absent in item", async () => {
257+
const driver = makeMockDriver({
258+
delimiter: "/",
259+
items: [
260+
{
261+
name: "plain.csv",
262+
size: 10n,
263+
isDir: false,
264+
additionalInfo: {},
265+
fullName: "plain.csv",
266+
directory: "",
267+
version: "v1",
268+
},
269+
],
270+
});
271+
272+
const result = await driver.listRemoteFilesWithFileStats(makeRemoteHandle(), "");
273+
expect(result.entries).toHaveLength(1);
274+
275+
const parsed = parseIndexHandle(result.entries[0].handle as any);
276+
expect(parsed.additionalInfo).toBeUndefined();
277+
});
278+
});

lib/node/pl-drivers/src/drivers/ls.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,18 @@ export interface InternalLsDriver extends sdk.LsDriver {
3434
* */
3535
getLocalFileHandle(localPath: string): Promise<sdk.LocalImportFileHandle>;
3636

37-
listRemoteFilesWithAdditionalInfo(
37+
listRemoteFilesWithFileStats(
3838
storage: sdk.StorageHandle,
3939
fullPath: string,
40-
): Promise<ListRemoteFilesResultWithAdditionalInfo>;
40+
): Promise<ListRemoteFilesResultWithFileStats>;
4141
}
4242

43-
export type ListRemoteFilesResultWithAdditionalInfo = {
43+
export type ListRemoteFilesResultWithFileStats = {
4444
parent?: string;
45-
entries: LsEntryWithAdditionalInfo[];
45+
entries: LsEntryWithFileStats[];
4646
};
4747

48-
export type LsEntryWithAdditionalInfo = sdk.LsEntry & {
48+
export type LsEntryWithFileStats = sdk.LsEntry & {
4949
size: number;
5050
};
5151

@@ -217,7 +217,8 @@ export class LsDriver implements InternalLsDriver {
217217
type: e.isDir ? "dir" : "file",
218218
name: e.name,
219219
fullPath: e.fullName,
220-
handle: createIndexImportHandle(storageData.storageId, e.fullName),
220+
// e.additionalInfo: federative identity envelope from backend TODO-4 (map<string,string>).
221+
handle: createIndexImportHandle(storageData.storageId, e.fullName, e.additionalInfo),
221222
})),
222223
};
223224
}
@@ -249,10 +250,10 @@ export class LsDriver implements InternalLsDriver {
249250
return { entries };
250251
}
251252

252-
public async listRemoteFilesWithAdditionalInfo(
253+
public async listRemoteFilesWithFileStats(
253254
storageHandle: sdk.StorageHandle,
254255
fullPath: string,
255-
): Promise<ListRemoteFilesResultWithAdditionalInfo> {
256+
): Promise<ListRemoteFilesResultWithFileStats> {
256257
const storageData = parseStorageHandle(storageHandle);
257258
if (!storageData.isRemote) {
258259
throw new Error(`Storage ${storageData.name} is not remote`);
@@ -266,7 +267,8 @@ export class LsDriver implements InternalLsDriver {
266267
type: e.isDir ? "dir" : "file",
267268
name: e.name,
268269
fullPath: e.fullName,
269-
handle: createIndexImportHandle(storageData.storageId, e.fullName),
270+
// e.additionalInfo: federative identity envelope from backend TODO-4 (map<string,string>).
271+
handle: createIndexImportHandle(storageData.storageId, e.fullName, e.additionalInfo),
270272
size: Number(e.size),
271273
})),
272274
};

lib/node/pl-drivers/src/drivers/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export const ImportFileHandleIndexData = z.object({
5454
storageId: z.string(),
5555
/** Path inside storage */
5656
path: z.string(),
57+
/**
58+
* Federative identity envelope from LsAPI.List.Response.ListItem.additional_info.
59+
* Absent for non-federative storages (backwards compatible: old handles parse unchanged).
60+
*/
61+
additionalInfo: z.record(z.string(), z.string()).optional(),
5762
});
5863
export type ImportFileHandleIndexData = z.infer<typeof ImportFileHandleIndexData>;
5964

0 commit comments

Comments
 (0)