@relayfile/core 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acl.d.ts CHANGED
@@ -18,6 +18,16 @@ export interface ParsedPermissionRule {
18
18
  kind: "scope" | "agent" | "workspace" | "public";
19
19
  value: string;
20
20
  }
21
+ export interface ScopeMatchContext {
22
+ workspaceId: string;
23
+ requestedPath?: string;
24
+ action?: "read" | "write" | "manage";
25
+ }
26
+ export interface PermissionEvaluationOptions {
27
+ scopeMatches?: (scope: string, claims: TokenClaims | null, context: ScopeMatchContext) => boolean;
28
+ requestedPath?: string;
29
+ action?: "read" | "write" | "manage";
30
+ }
21
31
  export declare function parsePermissionRule(raw: string): ParsedPermissionRule | null;
22
32
  /**
23
33
  * Evaluate ACL rules against agent claims.
@@ -30,5 +40,5 @@ export declare function parsePermissionRule(raw: string): ParsedPermissionRule |
30
40
  * Callers that need a default-deny posture should ensure every path has
31
41
  * at least one ACL marker in its ancestor directories.
32
42
  */
33
- export declare function filePermissionAllows(permissions: string[] | undefined, workspaceId: string, claims: TokenClaims | null): boolean;
43
+ export declare function filePermissionAllows(permissions: string[] | undefined, workspaceId: string, claims: TokenClaims | null, options?: PermissionEvaluationOptions): boolean;
34
44
  export declare function resolveFilePermissions(storage: StorageAdapter, path: string, includeTarget: boolean): string[];
package/dist/acl.js CHANGED
@@ -57,7 +57,7 @@ export function parsePermissionRule(raw) {
57
57
  * Callers that need a default-deny posture should ensure every path has
58
58
  * at least one ACL marker in its ancestor directories.
59
59
  */
60
- export function filePermissionAllows(permissions, workspaceId, claims) {
60
+ export function filePermissionAllows(permissions, workspaceId, claims, options = {}) {
61
61
  if (!permissions || permissions.length === 0) {
62
62
  return true;
63
63
  }
@@ -75,7 +75,13 @@ export function filePermissionAllows(permissions, workspaceId, claims) {
75
75
  match = true;
76
76
  break;
77
77
  case "scope":
78
- match = claims?.scopes.has(rule.value) ?? false;
78
+ match = options.scopeMatches
79
+ ? options.scopeMatches(rule.value, claims, {
80
+ workspaceId,
81
+ requestedPath: options.requestedPath,
82
+ action: options.action,
83
+ })
84
+ : (claims?.scopes.has(rule.value) ?? false);
79
85
  break;
80
86
  case "agent":
81
87
  match = claims?.agentName === rule.value;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { exportWorkspaceJson } from "./export.js";
3
+ import { queryFiles } from "./query.js";
4
+ import { listTree } from "./tree.js";
5
+ import { filePermissionAllows } from "./acl.js";
6
+ function claimsWith(scopes) {
7
+ return {
8
+ workspaceId: "ws_test",
9
+ agentName: "agent_test",
10
+ scopes: new Set(scopes),
11
+ };
12
+ }
13
+ function file(path, permissions = []) {
14
+ return {
15
+ path,
16
+ revision: "rev_1",
17
+ contentType: "text/markdown",
18
+ content: "body",
19
+ encoding: "utf-8",
20
+ provider: "github",
21
+ lastEditedAt: "2026-05-31T00:00:00.000Z",
22
+ semantics: {
23
+ permissions,
24
+ },
25
+ };
26
+ }
27
+ function storage(files) {
28
+ const byPath = new Map(files.map((entry) => [entry.path, entry]));
29
+ return {
30
+ getFile: (path) => byPath.get(path) ?? null,
31
+ listFiles: () => files,
32
+ putFile: () => undefined,
33
+ deleteFile: () => undefined,
34
+ appendEvent: () => undefined,
35
+ listEvents: () => ({ items: [], nextCursor: null }),
36
+ getRecentEvents: () => [],
37
+ getOperation: () => null,
38
+ putOperation: () => undefined,
39
+ listOperations: () => ({ items: [], nextCursor: null }),
40
+ nextRevision: () => "rev_2",
41
+ nextOperationId: () => "op_1",
42
+ nextEventId: () => "evt_1",
43
+ enqueueWriteback: () => undefined,
44
+ getPendingWritebacks: () => [],
45
+ getWorkspaceId: () => "ws_test",
46
+ };
47
+ }
48
+ describe("ACL scope matching", () => {
49
+ it("keeps exact scope matching as the default", () => {
50
+ const claims = claimsWith(["relayfile:fs:read:/github/*"]);
51
+ expect(filePermissionAllows(["scope:relayfile:fs:read:/github/LAYOUT.md"], "ws_test", claims)).toBe(false);
52
+ expect(filePermissionAllows(["scope:relayfile:fs:read:/github/*"], "ws_test", claims)).toBe(true);
53
+ });
54
+ it("lets callers inject path-aware scope matching for tree, query, and export", () => {
55
+ const claims = claimsWith(["relayfile:fs:read:/github/*"]);
56
+ const rows = [
57
+ file("/.relayfile.acl", ["scope:relayfile:fs:read:/github/LAYOUT.md"]),
58
+ file("/github/LAYOUT.md"),
59
+ ];
60
+ const repo = storage(rows);
61
+ const scopeMatches = vi.fn((scope, tokenClaims, context) => scope === "relayfile:fs:read:/github/LAYOUT.md" &&
62
+ tokenClaims?.scopes.has("relayfile:fs:read:/github/*") === true &&
63
+ context.action === "read" &&
64
+ context.requestedPath === "/github/LAYOUT.md");
65
+ expect(listTree(repo, { path: "/github", depth: 1 }, claims).entries).toEqual([]);
66
+ const aclOptions = { scopeMatches };
67
+ expect(listTree(repo, { path: "/github", depth: 1 }, claims, aclOptions).entries.map((entry) => entry.path)).toEqual(["/github/LAYOUT.md"]);
68
+ expect(queryFiles(repo, { path: "/github" }, claims, aclOptions).items.map((entry) => entry.path)).toEqual(["/github/LAYOUT.md"]);
69
+ expect(exportWorkspaceJson(repo, claims, aclOptions).map((entry) => entry.path)).toEqual([
70
+ "/github/LAYOUT.md",
71
+ ]);
72
+ expect(scopeMatches).toHaveBeenCalled();
73
+ });
74
+ });
package/dist/export.d.ts CHANGED
@@ -7,10 +7,10 @@
7
7
  * - patch export
8
8
  */
9
9
  import type { StorageAdapter, FileRow } from "./storage.js";
10
- import type { TokenClaims } from "./acl.js";
10
+ import type { PermissionEvaluationOptions, TokenClaims } from "./acl.js";
11
11
  export type ExportFormat = "json" | "tar" | "patch";
12
- export declare function exportWorkspaceJson(storage: StorageAdapter, claims: TokenClaims | null): FileRow[];
13
- export declare function exportWorkspacePatch(storage: StorageAdapter, claims: TokenClaims | null): string;
14
- export declare function exportWorkspaceTarGzip(storage: StorageAdapter, claims: TokenClaims | null): Promise<ArrayBuffer>;
12
+ export declare function exportWorkspaceJson(storage: StorageAdapter, claims: TokenClaims | null, aclOptions?: PermissionEvaluationOptions): FileRow[];
13
+ export declare function exportWorkspacePatch(storage: StorageAdapter, claims: TokenClaims | null, aclOptions?: PermissionEvaluationOptions): string;
14
+ export declare function exportWorkspaceTarGzip(storage: StorageAdapter, claims: TokenClaims | null, aclOptions?: PermissionEvaluationOptions): Promise<ArrayBuffer>;
15
15
  export declare function buildUnifiedPatch(files: FileRow[]): string;
16
16
  export declare function buildTarGzip(files: FileRow[]): Promise<ArrayBuffer>;
package/dist/export.js CHANGED
@@ -7,20 +7,24 @@
7
7
  * - patch export
8
8
  */
9
9
  import { filePermissionAllows, resolveFilePermissions } from "./acl.js";
10
- export function exportWorkspaceJson(storage, claims) {
10
+ export function exportWorkspaceJson(storage, claims, aclOptions = {}) {
11
11
  const workspaceId = storage.getWorkspaceId();
12
12
  return storage
13
13
  .listFiles()
14
14
  .slice()
15
15
  .sort((left, right) => left.path.localeCompare(right.path))
16
- .filter((row) => filePermissionAllows(resolveFilePermissions(storage, row.path, true), workspaceId, claims))
16
+ .filter((row) => filePermissionAllows(resolveFilePermissions(storage, row.path, true), workspaceId, claims, {
17
+ ...aclOptions,
18
+ action: aclOptions.action ?? "read",
19
+ requestedPath: row.path,
20
+ }))
17
21
  .map((row) => materializeFile(storage, row));
18
22
  }
19
- export function exportWorkspacePatch(storage, claims) {
20
- return buildUnifiedPatch(exportWorkspaceJson(storage, claims));
23
+ export function exportWorkspacePatch(storage, claims, aclOptions = {}) {
24
+ return buildUnifiedPatch(exportWorkspaceJson(storage, claims, aclOptions));
21
25
  }
22
- export async function exportWorkspaceTarGzip(storage, claims) {
23
- return buildTarGzip(exportWorkspaceJson(storage, claims));
26
+ export async function exportWorkspaceTarGzip(storage, claims, aclOptions = {}) {
27
+ return buildTarGzip(exportWorkspaceJson(storage, claims, aclOptions));
24
28
  }
25
29
  export function buildUnifiedPatch(files) {
26
30
  if (files.length === 0) {
package/dist/query.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * - cursor pagination
8
8
  */
9
9
  import type { StorageAdapter, Paginated, PaginationOptions } from "./storage.js";
10
- import { type TokenClaims } from "./acl.js";
10
+ import { type PermissionEvaluationOptions, type TokenClaims } from "./acl.js";
11
11
  export interface QueryOptions extends PaginationOptions {
12
12
  path?: string;
13
13
  provider?: string;
@@ -27,4 +27,4 @@ export interface QueryResultItem {
27
27
  relations?: string[];
28
28
  comments?: string[];
29
29
  }
30
- export declare function queryFiles(storage: StorageAdapter, options: QueryOptions, claims: TokenClaims | null): Paginated<QueryResultItem>;
30
+ export declare function queryFiles(storage: StorageAdapter, options: QueryOptions, claims: TokenClaims | null, aclOptions?: PermissionEvaluationOptions): Paginated<QueryResultItem>;
package/dist/query.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - cursor pagination
8
8
  */
9
9
  import { filePermissionAllows, resolveFilePermissions, } from "./acl.js";
10
- export function queryFiles(storage, options, claims) {
10
+ export function queryFiles(storage, options, claims, aclOptions = {}) {
11
11
  const base = normalizePath(options.path ?? "/");
12
12
  const provider = normalizeProvider(options.provider);
13
13
  const relation = options.relation?.trim() ?? "";
@@ -47,7 +47,11 @@ export function queryFiles(storage, options, claims) {
47
47
  if (!propertiesMatch(semantics.properties, expectedProperties)) {
48
48
  continue;
49
49
  }
50
- if (!filePermissionAllows(effectivePermissions, workspaceId, claims)) {
50
+ if (!filePermissionAllows(effectivePermissions, workspaceId, claims, {
51
+ ...aclOptions,
52
+ action: aclOptions.action ?? "read",
53
+ requestedPath: row.path,
54
+ })) {
51
55
  continue;
52
56
  }
53
57
  items.push({
package/dist/tree.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * - ancestor/descendant path utilities
10
10
  */
11
11
  import type { StorageAdapter, PaginationOptions } from "./storage.js";
12
- import { type TokenClaims } from "./acl.js";
12
+ import { type PermissionEvaluationOptions, type TokenClaims } from "./acl.js";
13
13
  export interface TreeEntry {
14
14
  path: string;
15
15
  type: "file" | "dir";
@@ -30,6 +30,6 @@ export interface ListTreeOptions extends PaginationOptions {
30
30
  path?: string;
31
31
  depth?: number;
32
32
  }
33
- export declare function listTree(storage: StorageAdapter, options: ListTreeOptions, claims: TokenClaims | null): TreeResult;
33
+ export declare function listTree(storage: StorageAdapter, options: ListTreeOptions, claims: TokenClaims | null, aclOptions?: PermissionEvaluationOptions): TreeResult;
34
34
  export declare function ancestorDirectories(path: string): string[];
35
35
  export declare function joinPath(dir: string, filename: string): string;
package/dist/tree.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - ancestor/descendant path utilities
10
10
  */
11
11
  import { filePermissionAllows, resolveFilePermissions, } from "./acl.js";
12
- export function listTree(storage, options, claims) {
12
+ export function listTree(storage, options, claims, aclOptions = {}) {
13
13
  const base = normalizePath(options.path ?? "/");
14
14
  const maxDepth = options.depth && options.depth > 0 ? options.depth : 1;
15
15
  const workspaceId = storage.getWorkspaceId();
@@ -19,7 +19,11 @@ export function listTree(storage, options, claims) {
19
19
  if (filePath === base || !isWithinBase(filePath, base)) {
20
20
  continue;
21
21
  }
22
- if (!filePermissionAllows(resolveFilePermissions(storage, filePath, true), workspaceId, claims)) {
22
+ if (!filePermissionAllows(resolveFilePermissions(storage, filePath, true), workspaceId, claims, {
23
+ ...aclOptions,
24
+ action: aclOptions.action ?? "read",
25
+ requestedPath: filePath,
26
+ })) {
23
27
  continue;
24
28
  }
25
29
  const rest = filePath.slice(base === "/" ? 1 : base.length + 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/core",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Shared business logic for relayfile — file operations, ACL, queries, events, and writeback lifecycle",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",