@dwk/remotestorage 0.1.0-beta.0
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/LICENSE +15 -0
- package/README.md +131 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +108 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +132 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +67 -0
- package/dist/config.js.map +1 -0
- package/dist/cors.d.ts +23 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +56 -0
- package/dist/cors.js.map +1 -0
- package/dist/discovery.d.ts +31 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +35 -0
- package/dist/discovery.js.map +1 -0
- package/dist/encoding.d.ts +11 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +21 -0
- package/dist/encoding.js.map +1 -0
- package/dist/folder.d.ts +78 -0
- package/dist/folder.d.ts.map +1 -0
- package/dist/folder.js +0 -0
- package/dist/folder.js.map +1 -0
- package/dist/gc.d.ts +23 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +34 -0
- package/dist/gc.js.map +1 -0
- package/dist/handler.d.ts +21 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +166 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +35 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +119 -0
- package/dist/jwt.js.map +1 -0
- package/dist/log.d.ts +29 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +27 -0
- package/dist/log.js.map +1 -0
- package/dist/scope.d.ts +54 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +83 -0
- package/dist/scope.js.map +1 -0
- package/dist/storage.d.ts +22 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +313 -0
- package/dist/storage.js.map +1 -0
- package/package.json +50 -0
- package/src/auth.ts +146 -0
- package/src/config.ts +197 -0
- package/src/cors.ts +68 -0
- package/src/discovery.ts +48 -0
- package/src/encoding.ts +22 -0
- package/src/folder.ts +0 -0
- package/src/gc.ts +50 -0
- package/src/handler.ts +225 -0
- package/src/index.ts +84 -0
- package/src/jwt.ts +155 -0
- package/src/log.ts +31 -0
- package/src/scope.ts +99 -0
- package/src/storage.ts +398 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/remotestorage` — an Unhosted-style remoteStorage personal data vault.
|
|
3
|
+
*
|
|
4
|
+
* A per-user document vault that "no-backend" web apps read and write over a
|
|
5
|
+
* plain HTTP `GET`/`PUT`/`DELETE` API, scoped by OAuth 2.0 bearer tokens
|
|
6
|
+
* (draft-dejong-remotestorage). It is a *competing* personal-data protocol to
|
|
7
|
+
* Solid — simpler, document-oriented, no RDF — filed for completeness, **not**
|
|
8
|
+
* as a recommendation alongside `@dwk/solid-pod`.
|
|
9
|
+
*
|
|
10
|
+
* Its value here is that it rides on the **same backing store** the Pod uses:
|
|
11
|
+
* `@dwk/store` is a generic `key → { rdf | blob }` pointer map, so remoteStorage
|
|
12
|
+
* documents are just its blob tier, and the only library addition this package
|
|
13
|
+
* required is the generic `list(prefix)` projection (which `@dwk/solid-pod`
|
|
14
|
+
* could use for LDP container enumeration too). This package ships a thin
|
|
15
|
+
* per-account Durable Object that reuses `@dwk/store`; the divergent auth model
|
|
16
|
+
* (plain OAuth bearer + per-module `:r`/`:rw` scopes + a public `/public/` tree,
|
|
17
|
+
* not DPoP/WAC) and folder semantics live entirely here.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/packages/remotestorage.md
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { createRemoteStorage, type RemoteStorageHandler } from "./handler";
|
|
24
|
+
export { createRemoteStorageGc, type RemoteStorageGcHandler } from "./gc";
|
|
25
|
+
export { RemoteStorageObject } from "./storage";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
resolveConfig,
|
|
29
|
+
defaultParsePath,
|
|
30
|
+
type RemoteStorageConfig,
|
|
31
|
+
type RemoteStorageEnv,
|
|
32
|
+
type RemoteStorageGcEnv,
|
|
33
|
+
type ResolvedConfig,
|
|
34
|
+
type ParsedPath,
|
|
35
|
+
} from "./config";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
authenticate,
|
|
39
|
+
bearerToken,
|
|
40
|
+
type RemoteStorageAuth,
|
|
41
|
+
type AuthResult,
|
|
42
|
+
type AuthFailureReason,
|
|
43
|
+
} from "./auth";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
parseScopes,
|
|
47
|
+
authorizeScopes,
|
|
48
|
+
moduleForPath,
|
|
49
|
+
isFolderPath,
|
|
50
|
+
isPublicDocument,
|
|
51
|
+
ROOT_MODULE,
|
|
52
|
+
type RemoteStorageScope,
|
|
53
|
+
type ScopeMode,
|
|
54
|
+
} from "./scope";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
buildFolderModel,
|
|
58
|
+
renderFolderDescription,
|
|
59
|
+
hashSignature,
|
|
60
|
+
FOLDER_DESCRIPTION_TYPE,
|
|
61
|
+
FOLDER_DESCRIPTION_CONTEXT,
|
|
62
|
+
type FolderModel,
|
|
63
|
+
type FolderDocument,
|
|
64
|
+
type FolderSubfolder,
|
|
65
|
+
type FolderDescription,
|
|
66
|
+
type FolderItem,
|
|
67
|
+
} from "./folder";
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
corsHeaders,
|
|
71
|
+
preflightResponse,
|
|
72
|
+
withCors,
|
|
73
|
+
ALLOWED_METHODS,
|
|
74
|
+
} from "./cors";
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
remoteStorageLink,
|
|
78
|
+
REMOTESTORAGE_REL,
|
|
79
|
+
REMOTESTORAGE_VERSION,
|
|
80
|
+
type RemoteStorageLinkConfig,
|
|
81
|
+
} from "./discovery";
|
|
82
|
+
|
|
83
|
+
export { RemoteStorageLogEvent } from "./log";
|
|
84
|
+
export type { Logger, Metrics } from "@dwk/log";
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JWT decode and JWKS-based signature verification for the built-in
|
|
3
|
+
* bearer-token verifier.
|
|
4
|
+
*
|
|
5
|
+
* remoteStorage uses **plain OAuth 2.0 bearer tokens** (no DPoP); a deployment
|
|
6
|
+
* that issues self-contained JWT access tokens can have this package verify them
|
|
7
|
+
* directly against the issuer's public JWKS. The module decodes the compact JWS,
|
|
8
|
+
* selects the key by `kid`/`kty`, and verifies with Web Crypto; claim policy
|
|
9
|
+
* (issuer/audience/expiry/`scope`) lives in `auth.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Only asymmetric algorithms are accepted. `HS*` and `none` are refused: an
|
|
12
|
+
* access token must be signed by the issuer's private key, never a symmetric
|
|
13
|
+
* secret a resource server could not safely hold.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { base64urlToBytes, base64urlToText } from "./encoding";
|
|
17
|
+
|
|
18
|
+
/** Asymmetric JOSE algorithms accepted for issuer-signed access tokens. */
|
|
19
|
+
export type JwtAlgorithm = "ES256" | "ES384" | "RS256" | "PS256";
|
|
20
|
+
|
|
21
|
+
interface AlgSpec {
|
|
22
|
+
kty: "EC" | "RSA";
|
|
23
|
+
crv?: string;
|
|
24
|
+
importParams: { name: string; namedCurve?: string; hash?: string };
|
|
25
|
+
verifyParams: { name: string; hash?: string; saltLength?: number };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// We avoid the DOM lib's algorithm-parameter type names (not declared by
|
|
29
|
+
// @cloudflare/workers-types); these objects are accepted structurally by
|
|
30
|
+
// crypto.subtle.importKey / verify, mirroring `@dwk/dpop` and `@dwk/solid-pod`.
|
|
31
|
+
const ALGS: Record<JwtAlgorithm, AlgSpec> = {
|
|
32
|
+
ES256: {
|
|
33
|
+
kty: "EC",
|
|
34
|
+
crv: "P-256",
|
|
35
|
+
importParams: { name: "ECDSA", namedCurve: "P-256" },
|
|
36
|
+
verifyParams: { name: "ECDSA", hash: "SHA-256" },
|
|
37
|
+
},
|
|
38
|
+
ES384: {
|
|
39
|
+
kty: "EC",
|
|
40
|
+
crv: "P-384",
|
|
41
|
+
importParams: { name: "ECDSA", namedCurve: "P-384" },
|
|
42
|
+
verifyParams: { name: "ECDSA", hash: "SHA-384" },
|
|
43
|
+
},
|
|
44
|
+
RS256: {
|
|
45
|
+
kty: "RSA",
|
|
46
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
47
|
+
verifyParams: { name: "RSASSA-PKCS1-v1_5" },
|
|
48
|
+
},
|
|
49
|
+
PS256: {
|
|
50
|
+
kty: "RSA",
|
|
51
|
+
importParams: { name: "RSA-PSS", hash: "SHA-256" },
|
|
52
|
+
verifyParams: { name: "RSA-PSS", saltLength: 32 },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A decoded JWT: its header, claims, and the raw segments for verification. */
|
|
57
|
+
export interface DecodedJwt {
|
|
58
|
+
readonly header: Record<string, unknown>;
|
|
59
|
+
readonly payload: Record<string, unknown>;
|
|
60
|
+
readonly signingInput: string;
|
|
61
|
+
readonly signature: Uint8Array;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decode a compact JWS into its header, payload, and signature. Returns `null`
|
|
70
|
+
* for anything that is not three base64url segments with JSON header/payload.
|
|
71
|
+
*/
|
|
72
|
+
export function decodeJwt(token: string): DecodedJwt | null {
|
|
73
|
+
if (typeof token !== "string") return null;
|
|
74
|
+
const segments = token.split(".");
|
|
75
|
+
if (segments.length !== 3) return null;
|
|
76
|
+
const [headerSeg, payloadSeg, signatureSeg] = segments as [
|
|
77
|
+
string,
|
|
78
|
+
string,
|
|
79
|
+
string,
|
|
80
|
+
];
|
|
81
|
+
try {
|
|
82
|
+
const header = JSON.parse(base64urlToText(headerSeg)) as unknown;
|
|
83
|
+
const payload = JSON.parse(base64urlToText(payloadSeg)) as unknown;
|
|
84
|
+
if (!isObject(header) || !isObject(payload)) return null;
|
|
85
|
+
return {
|
|
86
|
+
header,
|
|
87
|
+
payload,
|
|
88
|
+
signingInput: `${headerSeg}.${payloadSeg}`,
|
|
89
|
+
signature: base64urlToBytes(signatureSeg),
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a JWK could verify a signature under the given algorithm. */
|
|
97
|
+
function keyMatchesAlg(jwk: JsonWebKey, spec: AlgSpec): boolean {
|
|
98
|
+
if (jwk.kty !== spec.kty) return false;
|
|
99
|
+
if (spec.crv !== undefined && jwk.crv !== spec.crv) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Verify `decoded`'s signature against a JWKS. Selects the verification key by
|
|
105
|
+
* `kid` when the header carries one, otherwise tries every key compatible with
|
|
106
|
+
* the header `alg`. Returns `true` on the first key that verifies.
|
|
107
|
+
*/
|
|
108
|
+
export async function verifyJwtSignature(
|
|
109
|
+
decoded: DecodedJwt,
|
|
110
|
+
jwks: readonly JsonWebKey[],
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const alg = decoded.header.alg;
|
|
113
|
+
const spec = typeof alg === "string" ? ALGS[alg as JwtAlgorithm] : undefined;
|
|
114
|
+
if (!spec) return false;
|
|
115
|
+
|
|
116
|
+
const kid = decoded.header.kid;
|
|
117
|
+
const compatible = jwks.filter((jwk) => keyMatchesAlg(jwk, spec));
|
|
118
|
+
|
|
119
|
+
// When the token names a `kid`, pin verification to the key(s) carrying it; a
|
|
120
|
+
// `kid` matching no JWKS key is rejected rather than verified against
|
|
121
|
+
// unrelated keys (which would silently weaken `kid` pinning). Only when the
|
|
122
|
+
// header omits `kid` do we try every alg-compatible key.
|
|
123
|
+
let candidates = compatible;
|
|
124
|
+
if (typeof kid === "string") {
|
|
125
|
+
candidates = compatible.filter(
|
|
126
|
+
(jwk) => (jwk as { kid?: unknown }).kid === kid,
|
|
127
|
+
);
|
|
128
|
+
if (candidates.length === 0) return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The signing input is the same for every candidate key; encode it once.
|
|
132
|
+
const signingInput = new TextEncoder().encode(decoded.signingInput);
|
|
133
|
+
for (const jwk of candidates) {
|
|
134
|
+
try {
|
|
135
|
+
const key = await crypto.subtle.importKey(
|
|
136
|
+
"jwk",
|
|
137
|
+
jwk,
|
|
138
|
+
spec.importParams,
|
|
139
|
+
false,
|
|
140
|
+
["verify"],
|
|
141
|
+
);
|
|
142
|
+
const ok = await crypto.subtle.verify(
|
|
143
|
+
spec.verifyParams,
|
|
144
|
+
key,
|
|
145
|
+
decoded.signature,
|
|
146
|
+
signingInput,
|
|
147
|
+
);
|
|
148
|
+
if (ok) return true;
|
|
149
|
+
} catch {
|
|
150
|
+
// A key that fails to import (wrong shape) simply does not match; try the
|
|
151
|
+
// next candidate rather than aborting the whole verification.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/remotestorage` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* The vault authenticates OAuth bearer tokens and enforces per-module scopes at
|
|
5
|
+
* the edge; an auth/authz decision that is silently swallowed is an operational
|
|
6
|
+
* blind spot. Logging and metrics are opt-in via an injected {@link Logger} and
|
|
7
|
+
* {@link Metrics} (see `@dwk/log`) wired **once at the composition boundary**
|
|
8
|
+
* (the stateless front door). They **share this one vocabulary**: the same
|
|
9
|
+
* dotted event name is passed to the logger and the metrics sink so a log line
|
|
10
|
+
* and its counter line up.
|
|
11
|
+
*
|
|
12
|
+
* Fields follow the redaction policy: only machine-readable reason codes, HTTP
|
|
13
|
+
* method/status, and a sanitized subject host — never tokens, scopes verbatim,
|
|
14
|
+
* resource paths, or bodies. See `spec/observability.md`.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Stable event names emitted by `@dwk/remotestorage`. */
|
|
20
|
+
export const RemoteStorageLogEvent = {
|
|
21
|
+
/** A presented bearer token failed verification. Field: `reason`. */
|
|
22
|
+
AuthRejected: "remotestorage.auth.rejected",
|
|
23
|
+
/** A request authenticated successfully at the edge. Field: `subjectHost`. */
|
|
24
|
+
AuthAccepted: "remotestorage.auth.accepted",
|
|
25
|
+
/** A request lacked the scope (or auth) for the target. Fields: `method`, `status` (401/403). */
|
|
26
|
+
AccessDenied: "remotestorage.scope.denied",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Union of the event-name string literals in {@link RemoteStorageLogEvent}. */
|
|
30
|
+
export type RemoteStorageLogEvent =
|
|
31
|
+
(typeof RemoteStorageLogEvent)[keyof typeof RemoteStorageLogEvent];
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remoteStorage authorization model as pure, runtime-free logic: OAuth
|
|
3
|
+
* **scope** parsing, the module a storage path belongs to, and the read/write
|
|
4
|
+
* access decision. No Workers runtime, no store, no token verification — so it
|
|
5
|
+
* unit-tests in isolation and the front door and any future co-tenant share one
|
|
6
|
+
* audited rule set.
|
|
7
|
+
*
|
|
8
|
+
* remoteStorage scopes are space-delimited `<module>:<mode>` entries (draft
|
|
9
|
+
* §10): `mode` is `r` (read-only) or `rw` (read-write), and the module is a
|
|
10
|
+
* top-level folder name, or `*` for the whole vault. A module scope `m` grants
|
|
11
|
+
* access to both the private tree `/<m>/…` and the public tree `/public/<m>/…`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A granted access mode: read-only or read-write. */
|
|
15
|
+
export type ScopeMode = "r" | "rw";
|
|
16
|
+
|
|
17
|
+
/** A single parsed remoteStorage OAuth scope. */
|
|
18
|
+
export interface RemoteStorageScope {
|
|
19
|
+
/** Top-level module (folder) name, or {@link ROOT_MODULE} for the whole vault. */
|
|
20
|
+
readonly module: string;
|
|
21
|
+
/** Whether the scope grants writes (`rw`) or reads only (`r`). */
|
|
22
|
+
readonly mode: ScopeMode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The wildcard module name granting access to the entire vault. */
|
|
26
|
+
export const ROOT_MODULE = "*";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a space-delimited OAuth `scope` string into remoteStorage scopes.
|
|
30
|
+
* Malformed entries (no `:`, empty module, or a mode other than `r`/`rw`) are
|
|
31
|
+
* dropped rather than failing the whole token, so an unrelated scope a client
|
|
32
|
+
* also requested cannot deny access to a valid one.
|
|
33
|
+
*/
|
|
34
|
+
export function parseScopes(
|
|
35
|
+
raw: string | undefined | null,
|
|
36
|
+
): RemoteStorageScope[] {
|
|
37
|
+
if (!raw) return [];
|
|
38
|
+
const scopes: RemoteStorageScope[] = [];
|
|
39
|
+
for (const entry of raw.split(/\s+/)) {
|
|
40
|
+
if (entry === "") continue;
|
|
41
|
+
const colon = entry.lastIndexOf(":");
|
|
42
|
+
if (colon <= 0) continue;
|
|
43
|
+
const module = entry.slice(0, colon);
|
|
44
|
+
const mode = entry.slice(colon + 1);
|
|
45
|
+
if (mode !== "r" && mode !== "rw") continue;
|
|
46
|
+
scopes.push({ module, mode });
|
|
47
|
+
}
|
|
48
|
+
return scopes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a request path targets a folder (the trailing-slash convention). */
|
|
52
|
+
export function isFolderPath(path: string): boolean {
|
|
53
|
+
return path.endsWith("/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a path is a **publicly readable document**: it lives under `/public/`
|
|
58
|
+
* and is not itself a folder. Folder listings are never public, even under
|
|
59
|
+
* `/public/` (draft §10), so a trailing-slash path returns `false`.
|
|
60
|
+
*/
|
|
61
|
+
export function isPublicDocument(path: string): boolean {
|
|
62
|
+
return path.startsWith("/public/") && !isFolderPath(path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The top-level module a storage path belongs to, for scope matching. The
|
|
67
|
+
* leading `/public/` segment is transparent — `/public/photos/x` and
|
|
68
|
+
* `/photos/x` share the module `photos` — and the vault root (`/`, or
|
|
69
|
+
* `/public/`) maps to the empty module, which only the {@link ROOT_MODULE}
|
|
70
|
+
* (`*`) scope can reach.
|
|
71
|
+
*/
|
|
72
|
+
export function moduleForPath(path: string): string {
|
|
73
|
+
let rel = path.replace(/^\/+/, "");
|
|
74
|
+
if (rel === "public" || rel.startsWith("public/")) {
|
|
75
|
+
rel = rel.slice("public".length).replace(/^\/+/, "");
|
|
76
|
+
}
|
|
77
|
+
const slash = rel.indexOf("/");
|
|
78
|
+
return slash === -1 ? rel : rel.slice(0, slash);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether the granted `scopes` permit the requested access to `path`.
|
|
83
|
+
* `needWrite` selects read-vs-write: a write requires an `rw` scope, a read is
|
|
84
|
+
* satisfied by either. A scope matches when its module equals the path's module
|
|
85
|
+
* or is the `*` wildcard. The empty-module root (`/`) is reachable only via `*`.
|
|
86
|
+
*/
|
|
87
|
+
export function authorizeScopes(
|
|
88
|
+
scopes: readonly RemoteStorageScope[],
|
|
89
|
+
path: string,
|
|
90
|
+
needWrite: boolean,
|
|
91
|
+
): boolean {
|
|
92
|
+
const module = moduleForPath(path);
|
|
93
|
+
for (const scope of scopes) {
|
|
94
|
+
if (scope.module !== ROOT_MODULE && scope.module !== module) continue;
|
|
95
|
+
if (needWrite && scope.mode !== "rw") continue;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|