@_linked/server 2.19.0 → 2.21.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/lib/esm/backend.js +70 -11
  3. package/lib/esm/backend.js.map +1 -1
  4. package/lib/esm/data/{lincd-server.json → server.json} +9 -9
  5. package/lib/esm/index.d.ts +1 -1
  6. package/lib/esm/index.js +1 -1
  7. package/lib/esm/index.js.map +1 -1
  8. package/lib/esm/ontologies/lincd-server.d.ts +4 -47
  9. package/lib/esm/ontologies/lincd-server.js +4 -55
  10. package/lib/esm/ontologies/lincd-server.js.map +1 -1
  11. package/lib/esm/ontologies/lincd-server.register.d.ts +5 -1
  12. package/lib/esm/ontologies/lincd-server.register.js +3 -14
  13. package/lib/esm/ontologies/lincd-server.register.js.map +1 -1
  14. package/lib/esm/ontologies/server.d.ts +66 -0
  15. package/lib/esm/ontologies/server.js +63 -0
  16. package/lib/esm/ontologies/server.js.map +1 -0
  17. package/lib/esm/ontologies/server.register.d.ts +1 -0
  18. package/lib/esm/ontologies/server.register.js +24 -0
  19. package/lib/esm/ontologies/server.register.js.map +1 -0
  20. package/lib/esm/shapes/LincdAPI.js +42 -13
  21. package/lib/esm/shapes/LincdAPI.js.map +1 -1
  22. package/lib/esm/shapes/LincdWebApp.js +3 -3
  23. package/lib/esm/shapes/LincdWebApp.js.map +1 -1
  24. package/lib/esm/shapes/LinkedServer.d.ts +48 -4
  25. package/lib/esm/shapes/LinkedServer.js +230 -88
  26. package/lib/esm/shapes/LinkedServer.js.map +1 -1
  27. package/lib/esm/shapes/filestores/LocalFileStore.d.ts +16 -0
  28. package/lib/esm/shapes/filestores/LocalFileStore.js +77 -8
  29. package/lib/esm/shapes/filestores/LocalFileStore.js.map +1 -1
  30. package/lib/esm/shapes/index.d.ts +1 -1
  31. package/lib/esm/shapes/index.js +1 -1
  32. package/lib/esm/shapes/index.js.map +1 -1
  33. package/lib/esm/utils/jsonBodyParser.js +16 -1
  34. package/lib/esm/utils/jsonBodyParser.js.map +1 -1
  35. package/lib/esm/utils/queryPlane.d.ts +58 -0
  36. package/lib/esm/utils/queryPlane.js +206 -0
  37. package/lib/esm/utils/queryPlane.js.map +1 -0
  38. package/lib/esm/utils/rpcExposure.d.ts +53 -0
  39. package/lib/esm/utils/rpcExposure.js +199 -0
  40. package/lib/esm/utils/rpcExposure.js.map +1 -0
  41. package/package.json +5 -3
@@ -37,6 +37,22 @@ export declare class LocalFileStore implements IFileStore {
37
37
  constructor(n: string | {
38
38
  id: string;
39
39
  }, basePath?: string);
40
+ /**
41
+ * Resolve a key against the base folder, refusing anything that ends up
42
+ * outside it: dot segments (`../x`, `a/../../x`), and symlinks inside the
43
+ * folder that point out of it (`link/x` where `link -> /etc`, or a file that
44
+ * is itself such a link).
45
+ *
46
+ * The check runs twice. First lexically, before the file system is touched;
47
+ * then on real paths, with `fs.realpath` applied to the base folder and to
48
+ * the target (or, for a target that does not exist yet, its nearest existing
49
+ * ancestor), so a symlink anywhere along the way is followed before the
50
+ * comparison.
51
+ *
52
+ * Keys are joined, not resolved, so a leading `/` still means "relative to
53
+ * the base folder", as it always did.
54
+ */
55
+ private resolveInside;
40
56
  /**
41
57
  * Delete a file from the local filesystem
42
58
  * @param filePath The path to the file to delete, relative to the base upload folder
@@ -5,6 +5,45 @@ import { createHash } from 'node:crypto';
5
5
  import * as fsSync from 'node:fs';
6
6
  import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ function isInside(base, target) {
9
+ return target === base || target.startsWith(base + path.sep);
10
+ }
11
+ /**
12
+ * `fs.realpath` of `p`, or, when `p` does not exist (yet), the real path of its
13
+ * nearest existing ancestor with the missing segments appended. Every symlink
14
+ * on the existing part of the path is resolved, including a dangling one (a
15
+ * write through it would create its target), so the result is where a read or
16
+ * write of `p` would actually land.
17
+ */
18
+ async function realpathOfNearestExisting(p, depth = 0) {
19
+ if (depth > 40) {
20
+ throw new Error(`Too many symbolic links resolving ${p}`);
21
+ }
22
+ const missing = [];
23
+ let current = p;
24
+ for (;;) {
25
+ try {
26
+ const real = await fs.realpath(current);
27
+ return missing.length ? path.join(real, ...missing.reverse()) : real;
28
+ }
29
+ catch (err) {
30
+ if ((err === null || err === void 0 ? void 0 : err.code) !== 'ENOENT' && (err === null || err === void 0 ? void 0 : err.code) !== 'ENOTDIR')
31
+ throw err;
32
+ }
33
+ const link = await fs.readlink(current).catch(() => undefined);
34
+ if (link !== undefined) {
35
+ // a dangling symlink: follow it to where it points
36
+ const pointsTo = path.resolve(path.dirname(current), link);
37
+ const real = await realpathOfNearestExisting(pointsTo, depth + 1);
38
+ return missing.length ? path.join(real, ...missing.reverse()) : real;
39
+ }
40
+ const parent = path.dirname(current);
41
+ if (parent === current)
42
+ return p;
43
+ missing.push(path.basename(current));
44
+ current = parent;
45
+ }
46
+ }
8
47
  /**
9
48
  * A file store is not a Shape.
10
49
  *
@@ -28,13 +67,43 @@ export class LocalFileStore {
28
67
  this.accessURL = process.env.SITE_ROOT;
29
68
  this.basePath = basePath;
30
69
  }
70
+ /**
71
+ * Resolve a key against the base folder, refusing anything that ends up
72
+ * outside it: dot segments (`../x`, `a/../../x`), and symlinks inside the
73
+ * folder that point out of it (`link/x` where `link -> /etc`, or a file that
74
+ * is itself such a link).
75
+ *
76
+ * The check runs twice. First lexically, before the file system is touched;
77
+ * then on real paths, with `fs.realpath` applied to the base folder and to
78
+ * the target (or, for a target that does not exist yet, its nearest existing
79
+ * ancestor), so a symlink anywhere along the way is followed before the
80
+ * comparison.
81
+ *
82
+ * Keys are joined, not resolved, so a leading `/` still means "relative to
83
+ * the base folder", as it always did.
84
+ */
85
+ async resolveInside(filePath) {
86
+ const base = path.resolve(this.basePath);
87
+ const target = path.resolve(path.join(this.basePath, String(filePath !== null && filePath !== void 0 ? filePath : '')));
88
+ if (!isInside(base, target)) {
89
+ throw new Error(`File path escapes the file store: ${filePath}`);
90
+ }
91
+ const [realBase, realTarget] = await Promise.all([
92
+ realpathOfNearestExisting(base),
93
+ realpathOfNearestExisting(target),
94
+ ]);
95
+ if (!isInside(realBase, realTarget)) {
96
+ throw new Error(`File path escapes the file store: ${filePath}`);
97
+ }
98
+ return target;
99
+ }
31
100
  /**
32
101
  * Delete a file from the local filesystem
33
102
  * @param filePath The path to the file to delete, relative to the base upload folder
34
103
  * @returns A promise that resolves when the file is deleted
35
104
  */
36
- deleteFile(filePath) {
37
- const fileToDelete = path.join(this.basePath, filePath);
105
+ async deleteFile(filePath) {
106
+ const fileToDelete = await this.resolveInside(filePath);
38
107
  return fs.rm(fileToDelete);
39
108
  }
40
109
  /**
@@ -42,8 +111,8 @@ export class LocalFileStore {
42
111
  * @param filePath The path to the file to check, relative to the base upload folder
43
112
  * @returns A promise that resolves to true if the file exists, false otherwise
44
113
  */
45
- fileExists(filePath) {
46
- const fileToCheck = path.join(this.basePath, filePath);
114
+ async fileExists(filePath) {
115
+ const fileToCheck = await this.resolveInside(filePath);
47
116
  return fs
48
117
  .access(fileToCheck)
49
118
  .then(() => true)
@@ -54,8 +123,8 @@ export class LocalFileStore {
54
123
  * @param filePath The path to the file to get, relative to the base upload folder
55
124
  * @returns A promise that resolves to the file contents as a buffer, or null if the file does not exist
56
125
  */
57
- getFile(filePath) {
58
- const fileToGet = path.join(this.basePath, filePath);
126
+ async getFile(filePath) {
127
+ const fileToGet = await this.resolveInside(filePath);
59
128
  return fs.readFile(fileToGet).catch(() => null);
60
129
  }
61
130
  /**
@@ -208,7 +277,7 @@ export class LocalFileStore {
208
277
  const target = await this.resolveTarget(filePath, normalized.mimeType, suffixDuplicates);
209
278
  // resolved against basePath, exactly like every read method does, so what
210
279
  // is written here can be read back by the same key
211
- const targetFilePath = path.join(this.basePath, target.storedPath);
280
+ const targetFilePath = await this.resolveInside(target.storedPath);
212
281
  //make sure the target folder exists
213
282
  if (!fsSync.existsSync(path.dirname(targetFilePath))) {
214
283
  fsSync.mkdirSync(path.dirname(targetFilePath), { recursive: true });
@@ -230,7 +299,7 @@ export class LocalFileStore {
230
299
  * @returns The size and sha256 of the file, or null if it does not exist
231
300
  */
232
301
  async statFile(filePath) {
233
- const fileToStat = path.join(this.basePath, filePath);
302
+ const fileToStat = await this.resolveInside(filePath);
234
303
  let stat;
235
304
  try {
236
305
  stat = await fs.stat(fileToStat);
@@ -1 +1 @@
1
- {"version":3,"file":"LocalFileStore.js","sourceRoot":"","sources":["../../../../src/shapes/filestores/LocalFileStore.ts"],"names":[],"mappings":"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","sourcesContent":["import { relativeFileSystemUploadPath } from '@_linked/server-utils/utils/Upload';\nimport { publicUploadPath } from '@_linked/server-utils/utils/ServerPaths';\nimport {\n FileStat,\n IFileStore,\n SaveFileOptions,\n normalizeSaveFileOptions,\n} from '@_linked/core/interfaces/IFileStore';\nimport { createHash } from 'node:crypto';\nimport * as fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\n\n/**\n * What `saveFile` stored, and where.\n *\n * `storedPath` is the key as it ended up in the store: it is exactly what\n * `getFile`, `fileExists`, `deleteFile` and `statFile` expect, so a caller that\n * wants to verify what it just uploaded never has to guess a name back out of a\n * URL.\n */\nexport interface SavedFileLocation {\n /** The key the file is stored under, relative to the store's base folder. */\n storedPath: string;\n /** The public URL, the same string `saveFile` returns. */\n publicURL: string;\n}\n\n/**\n * A file store is not a Shape.\n *\n * This class used to `extends Shape`, but used nothing from it: no `this.id`, no\n * `this.uri`, no `nodeShape`, no Shape statics, no property decorators, and no\n * caller that treated it as a Shape. The only inherited behaviour was `super({id})`\n * writing an id that nothing ever read.\n *\n * Datasets and stores stopped being Shapes deliberately (core `0e8c86e`,\n * \"datasets are not shapes\"), and `Shape`'s instantiation guard is currently\n * DEFERRED naming this very class as the reason. Dropping the inheritance removes\n * one of the last obstacles to re-enabling it.\n */\nexport class LocalFileStore implements IFileStore {\n private readonly basePath: string;\n\n public readonly accessURL: string;\n\n /**\n * `n` names the store. It was previously also accepted as `{id}` because that is\n * `Shape`'s constructor signature; both forms are still taken so existing callers\n * keep working, but neither is stored as a node id any more.\n */\n constructor(\n n: string | { id: string },\n basePath: string = relativeFileSystemUploadPath\n ) {\n this.accessURL = process.env.SITE_ROOT;\n this.basePath = basePath;\n }\n\n /**\n * Delete a file from the local filesystem\n * @param filePath The path to the file to delete, relative to the base upload folder\n * @returns A promise that resolves when the file is deleted\n */\n deleteFile(filePath: string): Promise<void> {\n const fileToDelete = path.join(this.basePath, filePath);\n\n return fs.rm(fileToDelete);\n }\n\n /**\n * Check if a file exists on the local filesystem\n * @param filePath The path to the file to check, relative to the base upload folder\n * @returns A promise that resolves to true if the file exists, false otherwise\n */\n fileExists(filePath: string): Promise<boolean> {\n const fileToCheck = path.join(this.basePath, filePath);\n\n return fs\n .access(fileToCheck)\n .then(() => true)\n .catch(() => false);\n }\n\n /**\n * Get a file from the local filesystem\n * @param filePath The path to the file to get, relative to the base upload folder\n * @returns A promise that resolves to the file contents as a buffer, or null if the file does not exist\n */\n getFile(filePath: string): Promise<Buffer | null> {\n const fileToGet = path.join(this.basePath, filePath);\n\n return fs.readFile(fileToGet).catch(() => null);\n }\n\n /**\n * List every file in the store, recursively.\n *\n * @param prefix Only return keys starting with this string. Matched against\n * the returned (relative) key, so `listFiles('images/')` selects the\n * `images` folder and `listFiles('main-')` selects by file name.\n * @returns Keys relative to the base folder, exactly as `getFile`,\n * `fileExists`, `deleteFile` and `statFile` expect them.\n *\n * The keys are relative on purpose: every other method on this store resolves\n * its argument *against* `basePath`, so returning `data/uploads/x.txt` here\n * meant a caller who fed the result straight back into `getFile` looked for\n * `data/uploads/data/uploads/x.txt`. It also matches `S3FileStore`, which\n * strips the bucket prefix before returning.\n */\n async listFiles(prefix?: string): Promise<string[]> {\n const walk = async (relativeDir: string): Promise<string[]> => {\n const absoluteDir = path.join(this.basePath, relativeDir);\n\n let entries: fsSync.Dirent[];\n try {\n entries = await fs.readdir(absoluteDir, { withFileTypes: true });\n } catch (err) {\n console.warn('Error during listFiles', err);\n return [];\n }\n\n const nested = await Promise.all(\n entries.map((entry) => {\n // path.join('', 'x') === 'x', so the top level stays unprefixed\n const key = path.join(relativeDir, entry.name);\n // recurse into the subdirectory itself. This used to call\n // listFiles(prefix), which re-read basePath and so found the same\n // directory again -- unbounded recursion as soon as the store held\n // one folder.\n return entry.isDirectory() ? walk(key) : Promise.resolve([key]);\n })\n );\n\n return nested.flat();\n };\n\n const keys = await walk('');\n\n return prefix ? keys.filter((key) => key.startsWith(prefix)) : keys;\n }\n\n /**\n * Work out the key a save will use, and the URL that key is served under.\n *\n * This used to be `getUploadTarget` from `@_linked/server-utils`, which could\n * not be used here for two reasons:\n *\n * - it lowercases the name. That is HTTP-upload hygiene (browsers hand over\n * whatever the user's filesystem had, and `getUploadTarget` is still the\n * right place for it — `uploadSingleFileFromFormData` calls it before it\n * ever reaches a store), but it corrupts a key a program chose on purpose,\n * such as a Vite content hash: `main-hwqwrAvA.css`.\n * - it always resolves against `relativeFileSystemUploadPath`, ignoring this\n * store's `basePath`, so a store with a custom base folder wrote where it\n * could not read back.\n *\n * Characters that are not safe in a file name are still replaced, exactly as\n * `getUploadTarget` does it — only the case is left alone.\n */\n private async resolveTarget(\n filePath: string,\n mimeType: string | undefined,\n suffixDuplicates: boolean\n ): Promise<SavedFileLocation> {\n // alphanumerics, dot, underscore, dash and the path separator survive; a\n // run of anything else becomes one dash. Case is deliberately preserved.\n //\n // The dash has to be in that set. Without it a run of dashes collapsed to\n // one, so a Vite base64 content hash that happens to start with a dash —\n // `shapeCodeGenerator--2JmNvrO.js` — came back as\n // `shapeCodeGenerator-2JmNvrO.js`, a different key from the one the caller\n // asked for and from the one baked into the bundle.\n let sanitisedName = filePath.replace(/[^A-Za-z0-9\\._\\/-]+/g, '-');\n\n if (sanitisedName.indexOf('.') === -1) {\n //auto add an extension if none is present, based on the mimetype\n if (!mimeType) {\n throw new Error(\n 'This file does not have an extension and no mimetype was provided'\n );\n }\n sanitisedName = sanitisedName + '.' + mimeType.split('/')[1];\n }\n\n const ext = sanitisedName.split('.').pop();\n const withoutExt = sanitisedName.substring(\n 0,\n sanitisedName.length - (ext.length + 1)\n );\n\n // A 6 character random string avoids clobbering an existing file,\n // e.g. report.pdf -> report_312acb.pdf.\n //\n // It is only applied when the name is actually taken. This used to be\n // unconditional, which did prevent duplicates but also meant a caller never\n // got back the key it asked for: a content-hashed bundle handed over as\n // `main-hwqwrAvA.css` landed as `main-hwqwrAvA_x9k2ml.css`, so the name\n // baked into the HTML no longer resolved. Checking first keeps the\n // never-clobber guarantee -- a taken name still gets a suffix, and the\n // suffixed name is itself re-checked -- while leaving free names alone.\n // `S3FileStore` has always worked this way, so the two implementations of\n // IFileStore now read the flag the same.\n let storedPath = withoutExt + '.' + ext;\n\n if (suffixDuplicates) {\n // 36^6 is ~2 billion, so a second collision is already vanishingly\n // unlikely; the cap turns a pathological case into a clear error rather\n // than a spin.\n const maxAttempts = 10;\n let attempt = 0;\n\n while (await this.fileExists(storedPath)) {\n if (attempt++ >= maxAttempts) {\n throw new Error(\n `Could not find a free name for '${filePath}' after ${maxAttempts} attempts`\n );\n }\n\n storedPath =\n withoutExt + '_' + Math.random().toString(36).substring(2, 8) + '.' + ext;\n }\n }\n\n return {\n storedPath,\n publicURL: this.accessURL + publicUploadPath + '/' + storedPath,\n };\n }\n\n /**\n * Save a file to the local filesystem\n * @param filePath The path to save the file to, relative to the base folder\n * @param fileContent The contents of the file as a buffer\n * @param options Save options, or a string read as the old positional mimeType.\n * A local file has no headers to attach, so only `mimeType` (used to add a\n * missing extension) and `preventDuplicates` mean anything here;\n * `cacheControl` and `metadata` are accepted and ignored.\n * @param preventDuplicates Only honoured when `options` is a string\n * @returns A promise that resolves to the public URL of the file. Use\n * {@link saveFileWithPath} when you also need the key it was stored under.\n */\n async saveFile(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<string> {\n const { publicURL } = await this.saveFileWithPath(\n filePath,\n fileContent,\n options,\n preventDuplicates\n );\n\n return publicURL;\n }\n\n /**\n * Save a file and report both the key it was stored under and its URL.\n *\n * `saveFile` can only return one string, and `IFileStore` says that string is\n * a URL — but a caller that wants to verify what it just wrote needs the\n * *key*, which is what `statFile`, `getFile`, `fileExists` and `deleteFile`\n * take. That is what `storedPath` is: hand it straight back to any of them.\n *\n * The key equals the `filePath` given verbatim — case included — as long as\n * the name needs no sanitising and already has an extension. With\n * `preventDuplicates` left unspecified (this store's default, kept for upload\n * callers) that still holds for a name nothing else occupies; only a name\n * already taken gains a random suffix, and `storedPath` is the only place\n * that name is reported.\n */\n async saveFileWithPath(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<SavedFileLocation> {\n const normalized = normalizeSaveFileOptions(options, preventDuplicates);\n // Core reports an unspecified preventDuplicates as undefined, leaving the\n // default to each store. This one protects an existing file, so only an\n // explicit `false` allows an overwrite.\n const suffixDuplicates = normalized.preventDuplicates ?? true;\n\n const target = await this.resolveTarget(\n filePath,\n normalized.mimeType,\n suffixDuplicates\n );\n\n // resolved against basePath, exactly like every read method does, so what\n // is written here can be read back by the same key\n const targetFilePath = path.join(this.basePath, target.storedPath);\n\n //make sure the target folder exists\n if (!fsSync.existsSync(path.dirname(targetFilePath))) {\n fsSync.mkdirSync(path.dirname(targetFilePath), { recursive: true });\n }\n await fs.writeFile(targetFilePath, fileContent);\n\n return target;\n }\n\n /**\n * Read back the metadata of a stored file, for verify-after-upload.\n *\n * The path is resolved exactly like saveFile/getFile/fileExists/deleteFile\n * do, against this store's base folder, so the `storedPath` that\n * saveFileWithPath reports can be handed straight back here.\n *\n * The sha256 is computed on demand from the bytes: statFile is only called\n * during publish verification, not on every upload.\n *\n * @param filePath The path to the file, relative to the base upload folder\n * @returns The size and sha256 of the file, or null if it does not exist\n */\n async statFile(filePath: string): Promise<FileStat | null> {\n const fileToStat = path.join(this.basePath, filePath);\n\n let stat: fsSync.Stats;\n try {\n stat = await fs.stat(fileToStat);\n } catch (err) {\n return null;\n }\n if (!stat.isFile()) {\n return null;\n }\n\n const contents = await fs.readFile(fileToStat);\n const sha256 = createHash('sha256').update(contents).digest('hex');\n\n return { size: stat.size, sha256 };\n }\n}\n"]}
1
+ {"version":3,"file":"LocalFileStore.js","sourceRoot":"","sources":["../../../../src/shapes/filestores/LocalFileStore.ts"],"names":[],"mappings":"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","sourcesContent":["import { relativeFileSystemUploadPath } from '@_linked/server-utils/utils/Upload';\nimport { publicUploadPath } from '@_linked/server-utils/utils/ServerPaths';\nimport {\n FileStat,\n IFileStore,\n SaveFileOptions,\n normalizeSaveFileOptions,\n} from '@_linked/core/interfaces/IFileStore';\nimport { createHash } from 'node:crypto';\nimport * as fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\n\nfunction isInside(base: string, target: string): boolean {\n return target === base || target.startsWith(base + path.sep);\n}\n\n/**\n * `fs.realpath` of `p`, or, when `p` does not exist (yet), the real path of its\n * nearest existing ancestor with the missing segments appended. Every symlink\n * on the existing part of the path is resolved, including a dangling one (a\n * write through it would create its target), so the result is where a read or\n * write of `p` would actually land.\n */\nasync function realpathOfNearestExisting(p: string, depth = 0): Promise<string> {\n if (depth > 40) {\n throw new Error(`Too many symbolic links resolving ${p}`);\n }\n const missing: string[] = [];\n let current = p;\n for (;;) {\n try {\n const real = await fs.realpath(current);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n } catch (err: any) {\n if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;\n }\n const link = await fs.readlink(current).catch(() => undefined);\n if (link !== undefined) {\n // a dangling symlink: follow it to where it points\n const pointsTo = path.resolve(path.dirname(current), link);\n const real = await realpathOfNearestExisting(pointsTo, depth + 1);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n }\n const parent = path.dirname(current);\n if (parent === current) return p;\n missing.push(path.basename(current));\n current = parent;\n }\n}\n\n/**\n * What `saveFile` stored, and where.\n *\n * `storedPath` is the key as it ended up in the store: it is exactly what\n * `getFile`, `fileExists`, `deleteFile` and `statFile` expect, so a caller that\n * wants to verify what it just uploaded never has to guess a name back out of a\n * URL.\n */\nexport interface SavedFileLocation {\n /** The key the file is stored under, relative to the store's base folder. */\n storedPath: string;\n /** The public URL, the same string `saveFile` returns. */\n publicURL: string;\n}\n\n/**\n * A file store is not a Shape.\n *\n * This class used to `extends Shape`, but used nothing from it: no `this.id`, no\n * `this.uri`, no `nodeShape`, no Shape statics, no property decorators, and no\n * caller that treated it as a Shape. The only inherited behaviour was `super({id})`\n * writing an id that nothing ever read.\n *\n * Datasets and stores stopped being Shapes deliberately (core `0e8c86e`,\n * \"datasets are not shapes\"), and `Shape`'s instantiation guard is currently\n * DEFERRED naming this very class as the reason. Dropping the inheritance removes\n * one of the last obstacles to re-enabling it.\n */\nexport class LocalFileStore implements IFileStore {\n private readonly basePath: string;\n\n public readonly accessURL: string;\n\n /**\n * `n` names the store. It was previously also accepted as `{id}` because that is\n * `Shape`'s constructor signature; both forms are still taken so existing callers\n * keep working, but neither is stored as a node id any more.\n */\n constructor(\n n: string | { id: string },\n basePath: string = relativeFileSystemUploadPath\n ) {\n this.accessURL = process.env.SITE_ROOT;\n this.basePath = basePath;\n }\n\n /**\n * Resolve a key against the base folder, refusing anything that ends up\n * outside it: dot segments (`../x`, `a/../../x`), and symlinks inside the\n * folder that point out of it (`link/x` where `link -> /etc`, or a file that\n * is itself such a link).\n *\n * The check runs twice. First lexically, before the file system is touched;\n * then on real paths, with `fs.realpath` applied to the base folder and to\n * the target (or, for a target that does not exist yet, its nearest existing\n * ancestor), so a symlink anywhere along the way is followed before the\n * comparison.\n *\n * Keys are joined, not resolved, so a leading `/` still means \"relative to\n * the base folder\", as it always did.\n */\n private async resolveInside(filePath: string): Promise<string> {\n const base = path.resolve(this.basePath);\n const target = path.resolve(path.join(this.basePath, String(filePath ?? '')));\n if (!isInside(base, target)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n const [realBase, realTarget] = await Promise.all([\n realpathOfNearestExisting(base),\n realpathOfNearestExisting(target),\n ]);\n if (!isInside(realBase, realTarget)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n return target;\n }\n\n /**\n * Delete a file from the local filesystem\n * @param filePath The path to the file to delete, relative to the base upload folder\n * @returns A promise that resolves when the file is deleted\n */\n async deleteFile(filePath: string): Promise<void> {\n const fileToDelete = await this.resolveInside(filePath);\n\n return fs.rm(fileToDelete);\n }\n\n /**\n * Check if a file exists on the local filesystem\n * @param filePath The path to the file to check, relative to the base upload folder\n * @returns A promise that resolves to true if the file exists, false otherwise\n */\n async fileExists(filePath: string): Promise<boolean> {\n const fileToCheck = await this.resolveInside(filePath);\n\n return fs\n .access(fileToCheck)\n .then(() => true)\n .catch(() => false);\n }\n\n /**\n * Get a file from the local filesystem\n * @param filePath The path to the file to get, relative to the base upload folder\n * @returns A promise that resolves to the file contents as a buffer, or null if the file does not exist\n */\n async getFile(filePath: string): Promise<Buffer | null> {\n const fileToGet = await this.resolveInside(filePath);\n\n return fs.readFile(fileToGet).catch(() => null);\n }\n\n /**\n * List every file in the store, recursively.\n *\n * @param prefix Only return keys starting with this string. Matched against\n * the returned (relative) key, so `listFiles('images/')` selects the\n * `images` folder and `listFiles('main-')` selects by file name.\n * @returns Keys relative to the base folder, exactly as `getFile`,\n * `fileExists`, `deleteFile` and `statFile` expect them.\n *\n * The keys are relative on purpose: every other method on this store resolves\n * its argument *against* `basePath`, so returning `data/uploads/x.txt` here\n * meant a caller who fed the result straight back into `getFile` looked for\n * `data/uploads/data/uploads/x.txt`. It also matches `S3FileStore`, which\n * strips the bucket prefix before returning.\n */\n async listFiles(prefix?: string): Promise<string[]> {\n const walk = async (relativeDir: string): Promise<string[]> => {\n const absoluteDir = path.join(this.basePath, relativeDir);\n\n let entries: fsSync.Dirent[];\n try {\n entries = await fs.readdir(absoluteDir, { withFileTypes: true });\n } catch (err) {\n console.warn('Error during listFiles', err);\n return [];\n }\n\n const nested = await Promise.all(\n entries.map((entry) => {\n // path.join('', 'x') === 'x', so the top level stays unprefixed\n const key = path.join(relativeDir, entry.name);\n // recurse into the subdirectory itself. This used to call\n // listFiles(prefix), which re-read basePath and so found the same\n // directory again -- unbounded recursion as soon as the store held\n // one folder.\n return entry.isDirectory() ? walk(key) : Promise.resolve([key]);\n })\n );\n\n return nested.flat();\n };\n\n const keys = await walk('');\n\n return prefix ? keys.filter((key) => key.startsWith(prefix)) : keys;\n }\n\n /**\n * Work out the key a save will use, and the URL that key is served under.\n *\n * This used to be `getUploadTarget` from `@_linked/server-utils`, which could\n * not be used here for two reasons:\n *\n * - it lowercases the name. That is HTTP-upload hygiene (browsers hand over\n * whatever the user's filesystem had, and `getUploadTarget` is still the\n * right place for it — `uploadSingleFileFromFormData` calls it before it\n * ever reaches a store), but it corrupts a key a program chose on purpose,\n * such as a Vite content hash: `main-hwqwrAvA.css`.\n * - it always resolves against `relativeFileSystemUploadPath`, ignoring this\n * store's `basePath`, so a store with a custom base folder wrote where it\n * could not read back.\n *\n * Characters that are not safe in a file name are still replaced, exactly as\n * `getUploadTarget` does it — only the case is left alone.\n */\n private async resolveTarget(\n filePath: string,\n mimeType: string | undefined,\n suffixDuplicates: boolean\n ): Promise<SavedFileLocation> {\n // alphanumerics, dot, underscore, dash and the path separator survive; a\n // run of anything else becomes one dash. Case is deliberately preserved.\n //\n // The dash has to be in that set. Without it a run of dashes collapsed to\n // one, so a Vite base64 content hash that happens to start with a dash —\n // `shapeCodeGenerator--2JmNvrO.js` — came back as\n // `shapeCodeGenerator-2JmNvrO.js`, a different key from the one the caller\n // asked for and from the one baked into the bundle.\n let sanitisedName = filePath.replace(/[^A-Za-z0-9\\._\\/-]+/g, '-');\n\n if (sanitisedName.indexOf('.') === -1) {\n //auto add an extension if none is present, based on the mimetype\n if (!mimeType) {\n throw new Error(\n 'This file does not have an extension and no mimetype was provided'\n );\n }\n sanitisedName = sanitisedName + '.' + mimeType.split('/')[1];\n }\n\n const ext = sanitisedName.split('.').pop();\n const withoutExt = sanitisedName.substring(\n 0,\n sanitisedName.length - (ext.length + 1)\n );\n\n // A 6 character random string avoids clobbering an existing file,\n // e.g. report.pdf -> report_312acb.pdf.\n //\n // It is only applied when the name is actually taken. This used to be\n // unconditional, which did prevent duplicates but also meant a caller never\n // got back the key it asked for: a content-hashed bundle handed over as\n // `main-hwqwrAvA.css` landed as `main-hwqwrAvA_x9k2ml.css`, so the name\n // baked into the HTML no longer resolved. Checking first keeps the\n // never-clobber guarantee -- a taken name still gets a suffix, and the\n // suffixed name is itself re-checked -- while leaving free names alone.\n // `S3FileStore` has always worked this way, so the two implementations of\n // IFileStore now read the flag the same.\n let storedPath = withoutExt + '.' + ext;\n\n if (suffixDuplicates) {\n // 36^6 is ~2 billion, so a second collision is already vanishingly\n // unlikely; the cap turns a pathological case into a clear error rather\n // than a spin.\n const maxAttempts = 10;\n let attempt = 0;\n\n while (await this.fileExists(storedPath)) {\n if (attempt++ >= maxAttempts) {\n throw new Error(\n `Could not find a free name for '${filePath}' after ${maxAttempts} attempts`\n );\n }\n\n storedPath =\n withoutExt + '_' + Math.random().toString(36).substring(2, 8) + '.' + ext;\n }\n }\n\n return {\n storedPath,\n publicURL: this.accessURL + publicUploadPath + '/' + storedPath,\n };\n }\n\n /**\n * Save a file to the local filesystem\n * @param filePath The path to save the file to, relative to the base folder\n * @param fileContent The contents of the file as a buffer\n * @param options Save options, or a string read as the old positional mimeType.\n * A local file has no headers to attach, so only `mimeType` (used to add a\n * missing extension) and `preventDuplicates` mean anything here;\n * `cacheControl` and `metadata` are accepted and ignored.\n * @param preventDuplicates Only honoured when `options` is a string\n * @returns A promise that resolves to the public URL of the file. Use\n * {@link saveFileWithPath} when you also need the key it was stored under.\n */\n async saveFile(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<string> {\n const { publicURL } = await this.saveFileWithPath(\n filePath,\n fileContent,\n options,\n preventDuplicates\n );\n\n return publicURL;\n }\n\n /**\n * Save a file and report both the key it was stored under and its URL.\n *\n * `saveFile` can only return one string, and `IFileStore` says that string is\n * a URL — but a caller that wants to verify what it just wrote needs the\n * *key*, which is what `statFile`, `getFile`, `fileExists` and `deleteFile`\n * take. That is what `storedPath` is: hand it straight back to any of them.\n *\n * The key equals the `filePath` given verbatim — case included — as long as\n * the name needs no sanitising and already has an extension. With\n * `preventDuplicates` left unspecified (this store's default, kept for upload\n * callers) that still holds for a name nothing else occupies; only a name\n * already taken gains a random suffix, and `storedPath` is the only place\n * that name is reported.\n */\n async saveFileWithPath(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<SavedFileLocation> {\n const normalized = normalizeSaveFileOptions(options, preventDuplicates);\n // Core reports an unspecified preventDuplicates as undefined, leaving the\n // default to each store. This one protects an existing file, so only an\n // explicit `false` allows an overwrite.\n const suffixDuplicates = normalized.preventDuplicates ?? true;\n\n const target = await this.resolveTarget(\n filePath,\n normalized.mimeType,\n suffixDuplicates\n );\n\n // resolved against basePath, exactly like every read method does, so what\n // is written here can be read back by the same key\n const targetFilePath = await this.resolveInside(target.storedPath);\n\n //make sure the target folder exists\n if (!fsSync.existsSync(path.dirname(targetFilePath))) {\n fsSync.mkdirSync(path.dirname(targetFilePath), { recursive: true });\n }\n await fs.writeFile(targetFilePath, fileContent);\n\n return target;\n }\n\n /**\n * Read back the metadata of a stored file, for verify-after-upload.\n *\n * The path is resolved exactly like saveFile/getFile/fileExists/deleteFile\n * do, against this store's base folder, so the `storedPath` that\n * saveFileWithPath reports can be handed straight back here.\n *\n * The sha256 is computed on demand from the bytes: statFile is only called\n * during publish verification, not on every upload.\n *\n * @param filePath The path to the file, relative to the base upload folder\n * @returns The size and sha256 of the file, or null if it does not exist\n */\n async statFile(filePath: string): Promise<FileStat | null> {\n const fileToStat = await this.resolveInside(filePath);\n\n let stat: fsSync.Stats;\n try {\n stat = await fs.stat(fileToStat);\n } catch (err) {\n return null;\n }\n if (!stat.isFile()) {\n return null;\n }\n\n const contents = await fs.readFile(fileToStat);\n const sha256 = createHash('sha256').update(contents).digest('hex');\n\n return { size: stat.size, sha256 };\n }\n}\n"]}
@@ -1,4 +1,4 @@
1
- import '../ontologies/lincd-server.register.js';
1
+ import '../ontologies/server.register.js';
2
2
  import './LinkedServer.js';
3
3
  import './LincdAPI.js';
4
4
  import './LincdWebApp.js';
@@ -1,7 +1,7 @@
1
1
  // Registers every shape this package defines. Side-effect imports only: a
2
2
  // consumer (or the host app) can load this one module to get the full set of
3
3
  // shapes registered, without pulling in components, providers or other exports.
4
- import '../ontologies/lincd-server.register.js';
4
+ import '../ontologies/server.register.js';
5
5
  import './LinkedServer.js';
6
6
  import './LincdAPI.js';
7
7
  import './LincdWebApp.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/shapes/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,6EAA6E;AAC7E,gFAAgF;AAChF,OAAO,wCAAwC,CAAC;AAEhD,OAAO,mBAAmB,CAAC;AAC3B,OAAO,eAAe,CAAC;AACvB,OAAO,kBAAkB,CAAC","sourcesContent":["// Registers every shape this package defines. Side-effect imports only: a\n// consumer (or the host app) can load this one module to get the full set of\n// shapes registered, without pulling in components, providers or other exports.\nimport '../ontologies/lincd-server.register.js';\n\nimport './LinkedServer.js';\nimport './LincdAPI.js';\nimport './LincdWebApp.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/shapes/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,6EAA6E;AAC7E,gFAAgF;AAChF,OAAO,kCAAkC,CAAC;AAE1C,OAAO,mBAAmB,CAAC;AAC3B,OAAO,eAAe,CAAC;AACvB,OAAO,kBAAkB,CAAC","sourcesContent":["// Registers every shape this package defines. Side-effect imports only: a\n// consumer (or the host app) can load this one module to get the full set of\n// shapes registered, without pulling in components, providers or other exports.\nimport '../ontologies/server.register.js';\n\nimport './LinkedServer.js';\nimport './LincdAPI.js';\nimport './LincdWebApp.js';\n"]}
@@ -16,6 +16,21 @@ export const JSON_BODY_LIMIT = '50mb';
16
16
  * the behaviour pinned by `json-body-parser.test.ts`.
17
17
  */
18
18
  export function createJsonBodyParser() {
19
- return express.json({ limit: JSON_BODY_LIMIT });
19
+ return express.json({ limit: JSON_BODY_LIMIT, verify: keepRawBody });
20
+ }
21
+ /** The routes whose handlers get the body as received, on `request.rawBody`. */
22
+ const RAW_BODY_PATHS = new Set(['/api/select-raw']);
23
+ /**
24
+ * Keep the unparsed bytes of a raw SPARQL request on `request.rawBody`, so a
25
+ * raw query authorizer (`registerRawQueryAuthorizer` in server-utils) can
26
+ * verify a signature over the exact body. Only for those routes: every other
27
+ * body is parsed and its bytes dropped as before.
28
+ */
29
+ function keepRawBody(req, _res, buf) {
30
+ var _a, _b;
31
+ const path = String((_b = (_a = req.originalUrl) !== null && _a !== void 0 ? _a : req.url) !== null && _b !== void 0 ? _b : '').split('?')[0];
32
+ if (req.method === 'POST' && RAW_BODY_PATHS.has(path)) {
33
+ req.rawBody = buf;
34
+ }
20
35
  }
21
36
  //# sourceMappingURL=jsonBodyParser.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"jsonBodyParser.js","sourceRoot":"","sources":["../../../src/utils/jsonBodyParser.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC;AAEtC;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;AAClD,CAAC","sourcesContent":["import express from 'express';\n\n/**\n * Largest JSON body the server accepts. `Server.call` sends its arguments as\n * one JSON document, and some callers (bulk imports, base64 file payloads)\n * send far more than the 100kb default.\n */\nexport const JSON_BODY_LIMIT = '50mb';\n\n/**\n * The request body parser LinkedServer mounts in front of every route.\n *\n * It is Express's own `express.json()`, i.e. the body-parser 1.x that ships\n * inside Express 4 — the server does not depend on body-parser directly. It\n * parses `application/json` only: multipart uploads (handled with formidable\n * by the upload utilities) and urlencoded forms pass through with their stream\n * unread. Changing parser, or moving to Express 5 (body-parser 2), must keep\n * the behaviour pinned by `json-body-parser.test.ts`.\n */\nexport function createJsonBodyParser() {\n return express.json({ limit: JSON_BODY_LIMIT });\n}\n"]}
1
+ {"version":3,"file":"jsonBodyParser.js","sourceRoot":"","sources":["../../../src/utils/jsonBodyParser.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC;AAEtC;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;AACvE,CAAC;AAED,gFAAgF;AAChF,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAEpD;;;;;GAKG;AACH,SAAS,WAAW,CAAC,GAAQ,EAAE,IAAa,EAAE,GAAW;;IACvD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,MAAA,GAAG,CAAC,WAAW,mCAAI,GAAG,CAAC,GAAG,mCAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpE,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,IAAI,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;QACtD,GAAG,CAAC,OAAO,GAAG,GAAG,CAAC;IACpB,CAAC;AACH,CAAC","sourcesContent":["import express from 'express';\n\n/**\n * Largest JSON body the server accepts. `Server.call` sends its arguments as\n * one JSON document, and some callers (bulk imports, base64 file payloads)\n * send far more than the 100kb default.\n */\nexport const JSON_BODY_LIMIT = '50mb';\n\n/**\n * The request body parser LinkedServer mounts in front of every route.\n *\n * It is Express's own `express.json()`, i.e. the body-parser 1.x that ships\n * inside Express 4 — the server does not depend on body-parser directly. It\n * parses `application/json` only: multipart uploads (handled with formidable\n * by the upload utilities) and urlencoded forms pass through with their stream\n * unread. Changing parser, or moving to Express 5 (body-parser 2), must keep\n * the behaviour pinned by `json-body-parser.test.ts`.\n */\nexport function createJsonBodyParser() {\n return express.json({ limit: JSON_BODY_LIMIT, verify: keepRawBody });\n}\n\n/** The routes whose handlers get the body as received, on `request.rawBody`. */\nconst RAW_BODY_PATHS = new Set(['/api/select-raw']);\n\n/**\n * Keep the unparsed bytes of a raw SPARQL request on `request.rawBody`, so a\n * raw query authorizer (`registerRawQueryAuthorizer` in server-utils) can\n * verify a signature over the exact body. Only for those routes: every other\n * body is parsed and its bytes dropped as before.\n */\nfunction keepRawBody(req: any, _res: unknown, buf: Buffer) {\n const path = String(req.originalUrl ?? req.url ?? '').split('?')[0];\n if (req.method === 'POST' && RAW_BODY_PATHS.has(path)) {\n req.rawBody = buf;\n }\n}\n"]}
@@ -0,0 +1,58 @@
1
+ import { type ProtectedNodeCheck, type ProtectedNodeProbe, type QueryOperation } from '@_linked/server-utils/utils/QueryAccess';
2
+ /**
3
+ * Whether DSL-JSON carries a query-context reference (`{"@ctx": name}`) in any
4
+ * position: a select or count subject, an update target, a delete id, a
5
+ * where-clause operand or a mutation field value. Every DSL-JSON decoder in
6
+ * core reads a context reference from this one key.
7
+ */
8
+ export declare function containsContextRef(json: unknown): boolean;
9
+ /**
10
+ * A rehydrated query builder from the DSL-JSON a client sent. The result is
11
+ * what the store runs, so the access check and the store see the same query.
12
+ * A body that is not a query answers 400.
13
+ *
14
+ * A query-context reference (`{"@ctx": "user"}`) also answers 400. Core
15
+ * resolves one at lowering time against a process-wide context map, which on a
16
+ * server holds whatever some code last set there, not the caller's identity,
17
+ * and lowering runs twice (once for the access check, once in the store), so
18
+ * the two could even resolve it differently. A query built from client JSON
19
+ * names its nodes by id.
20
+ *
21
+ * Without context references, lowering a rehydrated builder is a pure function
22
+ * of the builder (closed and never mutated after `fromJSON`) and the shape
23
+ * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers
24
+ * from the same builder.
25
+ */
26
+ export declare function toQueryBuilder(query: any): any;
27
+ /** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */
28
+ export declare function lowerForAccessCheck(builder: any): unknown;
29
+ /**
30
+ * The `ASK` that answers a protected-node check: is any node the mutation would
31
+ * write or delete typed with a protected class, or a subclass of one?
32
+ * `undefined` when there is nothing to ask.
33
+ */
34
+ export declare function protectedNodeAsk(check: ProtectedNodeCheck): string | undefined;
35
+ /**
36
+ * The default protected-node lookup: one `ASK` on the dataset the mutation's
37
+ * shape routes to (run in the current call's context, so a routing dataset
38
+ * resolves it exactly as it will resolve the mutation). A dataset that cannot
39
+ * run SPARQL (`rawQuery`) cannot be checked, and the mutation is refused.
40
+ */
41
+ export declare const askProtectedNodes: ProtectedNodeProbe;
42
+ /**
43
+ * Replace the protected-node lookup (see `checkQueryAccess`): an app whose
44
+ * protected nodes live in a known store can answer it there. Pass `undefined`
45
+ * to restore the default `ASK`.
46
+ */
47
+ export declare function setProtectedNodeProbe(fn: ProtectedNodeProbe | undefined): void;
48
+ /**
49
+ * Check `builder` (rehydrated from `query` with `toQueryBuilder`) against the
50
+ * generic-plane rules. Throws a `ServerCallError` (400/401/403) when the current
51
+ * call may not run it; the caller then hands the same builder to the store.
52
+ *
53
+ * Deliberately returns nothing: a query builder is a thenable, so resolving an
54
+ * async function with one would run it.
55
+ */
56
+ export declare function authorizeGenericQuery(operation: QueryOperation, query: unknown, builder: any, endpoint: string): Promise<void>;
57
+ /** Check a raw SPARQL query; see `registerRawQueryAuthorizer`. */
58
+ export declare function authorizeRawQuery(query: unknown, endpoint: string): Promise<void>;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Authorization for the generic query plane: the endpoints that run a query the
3
+ * client built (`LincdServerBackendProvider.*Query` behind
4
+ * `/call/@_linked/server/*Query`, and `LincdAPI` `post_*` behind `/api/*`).
5
+ *
6
+ * The rules themselves (protected shapes, authorizers) are registered through
7
+ * `@_linked/server-utils/utils/QueryAccess`; see `checkQueryAccess` there for the
8
+ * order they apply in. This module turns the incoming query into the builder the
9
+ * store runs and the IR the rules read, and answers the rules' protected-node
10
+ * lookup with a SPARQL `ASK` against the store the mutation routes to.
11
+ */
12
+ import { fromJSON } from '@_linked/core';
13
+ import { lower } from '@_linked/core/queries/lower';
14
+ import { LinkedStorage } from '@_linked/core/utils/LinkedStorage';
15
+ import { getShapeClass } from '@_linked/core/utils/ShapeClass';
16
+ import { checkQueryAccess, } from '@_linked/server-utils/utils/QueryAccess';
17
+ import { ServerCallError } from '@_linked/server-utils/utils/ServerCallError';
18
+ import { getRpcExposureMode } from './rpcExposure.js';
19
+ /** The DSL-JSON tag of a query-context reference (`CONTEXT_REF_KEY` in core). */
20
+ const CONTEXT_REF_KEY = '@ctx';
21
+ /**
22
+ * Whether DSL-JSON carries a query-context reference (`{"@ctx": name}`) in any
23
+ * position: a select or count subject, an update target, a delete id, a
24
+ * where-clause operand or a mutation field value. Every DSL-JSON decoder in
25
+ * core reads a context reference from this one key.
26
+ */
27
+ export function containsContextRef(json) {
28
+ const seen = new Set();
29
+ const stack = [json];
30
+ while (stack.length) {
31
+ const value = stack.pop();
32
+ if (!value || typeof value !== 'object' || seen.has(value))
33
+ continue;
34
+ seen.add(value);
35
+ if (Array.isArray(value)) {
36
+ for (const item of value)
37
+ stack.push(item);
38
+ continue;
39
+ }
40
+ if (Object.prototype.hasOwnProperty.call(value, CONTEXT_REF_KEY))
41
+ return true;
42
+ for (const key of Object.keys(value))
43
+ stack.push(value[key]);
44
+ }
45
+ return false;
46
+ }
47
+ /**
48
+ * A rehydrated query builder from the DSL-JSON a client sent. The result is
49
+ * what the store runs, so the access check and the store see the same query.
50
+ * A body that is not a query answers 400.
51
+ *
52
+ * A query-context reference (`{"@ctx": "user"}`) also answers 400. Core
53
+ * resolves one at lowering time against a process-wide context map, which on a
54
+ * server holds whatever some code last set there, not the caller's identity,
55
+ * and lowering runs twice (once for the access check, once in the store), so
56
+ * the two could even resolve it differently. A query built from client JSON
57
+ * names its nodes by id.
58
+ *
59
+ * Without context references, lowering a rehydrated builder is a pure function
60
+ * of the builder (closed and never mutated after `fromJSON`) and the shape
61
+ * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers
62
+ * from the same builder.
63
+ */
64
+ export function toQueryBuilder(query) {
65
+ // A live builder passed by backend code (never a plain object from JSON).
66
+ if (query &&
67
+ typeof query === 'object' &&
68
+ Object.getPrototypeOf(query) !== Object.prototype &&
69
+ typeof query.__queryKind === 'string') {
70
+ return query;
71
+ }
72
+ if (containsContextRef(query)) {
73
+ throw new ServerCallError(400, 'Query context references (@ctx) are not accepted by the generic query endpoints');
74
+ }
75
+ try {
76
+ const builder = fromJSON(query);
77
+ if (!builder || typeof builder !== 'object')
78
+ throw new Error('not a query');
79
+ return builder;
80
+ }
81
+ catch (_a) {
82
+ throw new ServerCallError(400, 'Invalid query');
83
+ }
84
+ }
85
+ /** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */
86
+ export function lowerForAccessCheck(builder) {
87
+ try {
88
+ return lower(builder);
89
+ }
90
+ catch (_a) {
91
+ return undefined;
92
+ }
93
+ }
94
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
95
+ const RDFS_SUBCLASS_OF = 'http://www.w3.org/2000/01/rdf-schema#subClassOf';
96
+ /** `<iri>`, refusing anything that is not a plain absolute IRI. */
97
+ function iriTerm(value) {
98
+ if (typeof value !== 'string' ||
99
+ !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s<>"{}|^`\\\u0000-\u001f\u007f]*$/.test(value)) {
100
+ throw new ServerCallError(400, 'Invalid node id');
101
+ }
102
+ return `<${value}>`;
103
+ }
104
+ /**
105
+ * The `ASK` that answers a protected-node check: is any node the mutation would
106
+ * write or delete typed with a protected class, or a subclass of one?
107
+ * `undefined` when there is nothing to ask.
108
+ */
109
+ export function protectedNodeAsk(check) {
110
+ if (!check.classes.length)
111
+ return undefined;
112
+ const branches = [];
113
+ const owned = check.containsPredicates.length
114
+ ? '(' + check.containsPredicates.map(iriTerm).join('|') + ')+'
115
+ : undefined;
116
+ if (check.ids.length) {
117
+ branches.push(`{ VALUES ?n { ${check.ids.map(iriTerm).join(' ')} } }`);
118
+ }
119
+ if (owned && check.ownerIds.length) {
120
+ branches.push(`{ VALUES ?owner { ${check.ownerIds.map(iriTerm).join(' ')} } ?owner ${owned} ?n . }`);
121
+ }
122
+ for (const cls of check.scanClasses) {
123
+ branches.push(`{ ?n <${RDF_TYPE}> ${iriTerm(cls)} . }`);
124
+ if (owned)
125
+ branches.push(`{ ?s <${RDF_TYPE}> ${iriTerm(cls)} . ?s ${owned} ?n . }`);
126
+ }
127
+ if (!branches.length)
128
+ return undefined;
129
+ return (`ASK WHERE {\n ${branches.join('\n UNION ')}\n` +
130
+ ` ?n <${RDF_TYPE}> ?t .\n` +
131
+ ` VALUES ?c { ${check.classes.map(iriTerm).join(' ')} }\n` +
132
+ ` ?t <${RDFS_SUBCLASS_OF}>* ?c .\n}`);
133
+ }
134
+ /**
135
+ * The default protected-node lookup: one `ASK` on the dataset the mutation's
136
+ * shape routes to (run in the current call's context, so a routing dataset
137
+ * resolves it exactly as it will resolve the mutation). A dataset that cannot
138
+ * run SPARQL (`rawQuery`) cannot be checked, and the mutation is refused.
139
+ */
140
+ export const askProtectedNodes = async (check) => {
141
+ var _a;
142
+ const sparql = protectedNodeAsk(check);
143
+ if (!sparql)
144
+ return false;
145
+ let shapeClass;
146
+ try {
147
+ shapeClass = getShapeClass(check.shape);
148
+ }
149
+ catch (_b) {
150
+ shapeClass = undefined;
151
+ }
152
+ const dataset = (_a = LinkedStorage.getDatasetForShapeClass(shapeClass)) !== null && _a !== void 0 ? _a : LinkedStorage.getDefaultDataset();
153
+ if (!dataset || typeof dataset.rawQuery !== 'function') {
154
+ throw new Error(`the dataset for ${check.shape} cannot run SPARQL, so the nodes this mutation writes cannot be checked`);
155
+ }
156
+ const json = await dataset.rawQuery(sparql);
157
+ if (typeof (json === null || json === void 0 ? void 0 : json.boolean) !== 'boolean') {
158
+ throw new Error('the dataset did not answer the ASK with a boolean');
159
+ }
160
+ return json.boolean;
161
+ };
162
+ let probe = askProtectedNodes;
163
+ /**
164
+ * Replace the protected-node lookup (see `checkQueryAccess`): an app whose
165
+ * protected nodes live in a known store can answer it there. Pass `undefined`
166
+ * to restore the default `ASK`.
167
+ */
168
+ export function setProtectedNodeProbe(fn) {
169
+ if (fn !== undefined && typeof fn !== 'function') {
170
+ throw new TypeError('setProtectedNodeProbe: expected a function');
171
+ }
172
+ probe = fn !== null && fn !== void 0 ? fn : askProtectedNodes;
173
+ }
174
+ /**
175
+ * Check `builder` (rehydrated from `query` with `toQueryBuilder`) against the
176
+ * generic-plane rules. Throws a `ServerCallError` (400/401/403) when the current
177
+ * call may not run it; the caller then hands the same builder to the store.
178
+ *
179
+ * Deliberately returns nothing: a query builder is a thenable, so resolving an
180
+ * async function with one would run it.
181
+ */
182
+ export async function authorizeGenericQuery(operation, query, builder, endpoint) {
183
+ await checkQueryAccess({
184
+ operation,
185
+ query,
186
+ ir: lowerForAccessCheck(builder),
187
+ mode: getRpcExposureMode(),
188
+ endpoint,
189
+ probeProtectedNodes: probe,
190
+ });
191
+ }
192
+ /** Check a raw SPARQL query; see `registerRawQueryAuthorizer`. */
193
+ export async function authorizeRawQuery(query, endpoint) {
194
+ if (typeof query !== 'string') {
195
+ throw new ServerCallError(400, 'Invalid query');
196
+ }
197
+ await checkQueryAccess({
198
+ operation: 'select',
199
+ query,
200
+ ir: undefined,
201
+ raw: true,
202
+ mode: getRpcExposureMode(),
203
+ endpoint,
204
+ });
205
+ }
206
+ //# sourceMappingURL=queryPlane.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryPlane.js","sourceRoot":"","sources":["../../../src/utils/queryPlane.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Authorization for the generic query plane: the endpoints that run a query the\n * client built (`LincdServerBackendProvider.*Query` behind\n * `/call/@_linked/server/*Query`, and `LincdAPI` `post_*` behind `/api/*`).\n *\n * The rules themselves (protected shapes, authorizers) are registered through\n * `@_linked/server-utils/utils/QueryAccess`; see `checkQueryAccess` there for the\n * order they apply in. This module turns the incoming query into the builder the\n * store runs and the IR the rules read, and answers the rules' protected-node\n * lookup with a SPARQL `ASK` against the store the mutation routes to.\n */\nimport { fromJSON } from '@_linked/core';\nimport { lower } from '@_linked/core/queries/lower';\nimport { LinkedStorage } from '@_linked/core/utils/LinkedStorage';\nimport { getShapeClass } from '@_linked/core/utils/ShapeClass';\nimport {\n checkQueryAccess,\n type ProtectedNodeCheck,\n type ProtectedNodeProbe,\n type QueryOperation,\n} from '@_linked/server-utils/utils/QueryAccess';\nimport { ServerCallError } from '@_linked/server-utils/utils/ServerCallError';\nimport { getRpcExposureMode } from './rpcExposure.js';\n\n/** The DSL-JSON tag of a query-context reference (`CONTEXT_REF_KEY` in core). */\nconst CONTEXT_REF_KEY = '@ctx';\n\n/**\n * Whether DSL-JSON carries a query-context reference (`{\"@ctx\": name}`) in any\n * position: a select or count subject, an update target, a delete id, a\n * where-clause operand or a mutation field value. Every DSL-JSON decoder in\n * core reads a context reference from this one key.\n */\nexport function containsContextRef(json: unknown): boolean {\n const seen = new Set<object>();\n const stack: unknown[] = [json];\n while (stack.length) {\n const value = stack.pop();\n if (!value || typeof value !== 'object' || seen.has(value)) continue;\n seen.add(value);\n if (Array.isArray(value)) {\n for (const item of value) stack.push(item);\n continue;\n }\n if (Object.prototype.hasOwnProperty.call(value, CONTEXT_REF_KEY)) return true;\n for (const key of Object.keys(value)) stack.push((value as any)[key]);\n }\n return false;\n}\n\n/**\n * A rehydrated query builder from the DSL-JSON a client sent. The result is\n * what the store runs, so the access check and the store see the same query.\n * A body that is not a query answers 400.\n *\n * A query-context reference (`{\"@ctx\": \"user\"}`) also answers 400. Core\n * resolves one at lowering time against a process-wide context map, which on a\n * server holds whatever some code last set there, not the caller's identity,\n * and lowering runs twice (once for the access check, once in the store), so\n * the two could even resolve it differently. A query built from client JSON\n * names its nodes by id.\n *\n * Without context references, lowering a rehydrated builder is a pure function\n * of the builder (closed and never mutated after `fromJSON`) and the shape\n * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers\n * from the same builder.\n */\nexport function toQueryBuilder(query: any): any {\n // A live builder passed by backend code (never a plain object from JSON).\n if (\n query &&\n typeof query === 'object' &&\n Object.getPrototypeOf(query) !== Object.prototype &&\n typeof query.__queryKind === 'string'\n ) {\n return query;\n }\n if (containsContextRef(query)) {\n throw new ServerCallError(\n 400,\n 'Query context references (@ctx) are not accepted by the generic query endpoints'\n );\n }\n try {\n const builder = fromJSON(query);\n if (!builder || typeof builder !== 'object') throw new Error('not a query');\n return builder;\n } catch {\n throw new ServerCallError(400, 'Invalid query');\n }\n}\n\n/** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */\nexport function lowerForAccessCheck(builder: any): unknown {\n try {\n return lower(builder);\n } catch {\n return undefined;\n }\n}\n\nconst RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';\nconst RDFS_SUBCLASS_OF = 'http://www.w3.org/2000/01/rdf-schema#subClassOf';\n\n/** `<iri>`, refusing anything that is not a plain absolute IRI. */\nfunction iriTerm(value: unknown): string {\n if (\n typeof value !== 'string' ||\n !/^[A-Za-z][A-Za-z0-9+.-]*:[^\\s<>\"{}|^`\\\\\\u0000-\\u001f\\u007f]*$/.test(value)\n ) {\n throw new ServerCallError(400, 'Invalid node id');\n }\n return `<${value}>`;\n}\n\n/**\n * The `ASK` that answers a protected-node check: is any node the mutation would\n * write or delete typed with a protected class, or a subclass of one?\n * `undefined` when there is nothing to ask.\n */\nexport function protectedNodeAsk(check: ProtectedNodeCheck): string | undefined {\n if (!check.classes.length) return undefined;\n const branches: string[] = [];\n const owned = check.containsPredicates.length\n ? '(' + check.containsPredicates.map(iriTerm).join('|') + ')+'\n : undefined;\n if (check.ids.length) {\n branches.push(`{ VALUES ?n { ${check.ids.map(iriTerm).join(' ')} } }`);\n }\n if (owned && check.ownerIds.length) {\n branches.push(\n `{ VALUES ?owner { ${check.ownerIds.map(iriTerm).join(' ')} } ?owner ${owned} ?n . }`\n );\n }\n for (const cls of check.scanClasses) {\n branches.push(`{ ?n <${RDF_TYPE}> ${iriTerm(cls)} . }`);\n if (owned) branches.push(`{ ?s <${RDF_TYPE}> ${iriTerm(cls)} . ?s ${owned} ?n . }`);\n }\n if (!branches.length) return undefined;\n return (\n `ASK WHERE {\\n ${branches.join('\\n UNION ')}\\n` +\n ` ?n <${RDF_TYPE}> ?t .\\n` +\n ` VALUES ?c { ${check.classes.map(iriTerm).join(' ')} }\\n` +\n ` ?t <${RDFS_SUBCLASS_OF}>* ?c .\\n}`\n );\n}\n\n/**\n * The default protected-node lookup: one `ASK` on the dataset the mutation's\n * shape routes to (run in the current call's context, so a routing dataset\n * resolves it exactly as it will resolve the mutation). A dataset that cannot\n * run SPARQL (`rawQuery`) cannot be checked, and the mutation is refused.\n */\nexport const askProtectedNodes: ProtectedNodeProbe = async (check) => {\n const sparql = protectedNodeAsk(check);\n if (!sparql) return false;\n let shapeClass: any;\n try {\n shapeClass = getShapeClass(check.shape as any);\n } catch {\n shapeClass = undefined;\n }\n const dataset: any =\n LinkedStorage.getDatasetForShapeClass(shapeClass) ?? LinkedStorage.getDefaultDataset();\n if (!dataset || typeof dataset.rawQuery !== 'function') {\n throw new Error(\n `the dataset for ${check.shape} cannot run SPARQL, so the nodes this mutation writes cannot be checked`\n );\n }\n const json = await dataset.rawQuery(sparql);\n if (typeof json?.boolean !== 'boolean') {\n throw new Error('the dataset did not answer the ASK with a boolean');\n }\n return json.boolean;\n};\n\nlet probe: ProtectedNodeProbe = askProtectedNodes;\n\n/**\n * Replace the protected-node lookup (see `checkQueryAccess`): an app whose\n * protected nodes live in a known store can answer it there. Pass `undefined`\n * to restore the default `ASK`.\n */\nexport function setProtectedNodeProbe(fn: ProtectedNodeProbe | undefined): void {\n if (fn !== undefined && typeof fn !== 'function') {\n throw new TypeError('setProtectedNodeProbe: expected a function');\n }\n probe = fn ?? askProtectedNodes;\n}\n\n/**\n * Check `builder` (rehydrated from `query` with `toQueryBuilder`) against the\n * generic-plane rules. Throws a `ServerCallError` (400/401/403) when the current\n * call may not run it; the caller then hands the same builder to the store.\n *\n * Deliberately returns nothing: a query builder is a thenable, so resolving an\n * async function with one would run it.\n */\nexport async function authorizeGenericQuery(\n operation: QueryOperation,\n query: unknown,\n builder: any,\n endpoint: string\n): Promise<void> {\n await checkQueryAccess({\n operation,\n query,\n ir: lowerForAccessCheck(builder),\n mode: getRpcExposureMode(),\n endpoint,\n probeProtectedNodes: probe,\n });\n}\n\n/** Check a raw SPARQL query; see `registerRawQueryAuthorizer`. */\nexport async function authorizeRawQuery(query: unknown, endpoint: string): Promise<void> {\n if (typeof query !== 'string') {\n throw new ServerCallError(400, 'Invalid query');\n }\n await checkQueryAccess({\n operation: 'select',\n query,\n ir: undefined,\n raw: true,\n mode: getRpcExposureMode(),\n endpoint,\n });\n}\n"]}