feat(client): inbox mechanism, write-guard, SPARQL injection hardening
Polyfill capabilities landed for Festipod's T02 features (all generic,
zero-domain — the consumer injects the domain).
- inbox: implement the previously-stubbed namespace. post(target,{from?,
payload,ts?}) deposits {from,payload,ts} as RDF via docs.sparqlUpdate (the
real injected ng, never makeNg); read/materialize + watch emulate the curator
in-lib (deposits read via docs.sparqlQuery). `from` optional = anonymity.
- write-guard: caps.hasWritePolicy() + ng-proxy.sparql_update rejects when the
target doc is under a write policy and the current user lacks its write cap;
passthrough otherwise (no regression). Read-cap registry unchanged.
- sparql.ts (new): escapeLiteral / escapeIri / assertNuri, exported from index.
store-registry now escapes every literal and validates/encodes every IRI-
position value — closes a SPARQL-injection hole where an untrusted username
could inject triples into the shim (the account→doc-NURI trust root).
Tests: inbox, sparql (incl. injection), ng-proxy write-guard, isolation-active.
68 tests pass; tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,11 @@ export class CapRegistry {
|
||||
return this.readers.size > 0 || this.publicDocs.size > 0;
|
||||
}
|
||||
|
||||
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
||||
hasWritePolicy(): boolean {
|
||||
return this.writers.size > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.readers.clear();
|
||||
this.writers.clear();
|
||||
|
||||
+189
-24
@@ -1,29 +1,194 @@
|
||||
/**
|
||||
* Inbox — client side (deposit only). The MATERIALIZATION of deposits is the
|
||||
* curator's job (a separate package, deferred — see repo README), never the client's.
|
||||
* Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting-
|
||||
* point registration AND submission to the discovery index (see the discovery-
|
||||
* model decision: same `inbox.post` API, same watcher). GENERIC by construction:
|
||||
* this module knows no application domain (no meeting-point, no notification).
|
||||
* The consumer supplies the inbox document NURI and interprets the `payload`.
|
||||
*
|
||||
* ── TARGET vs POLYFILL ────────────────────────────────────────────────────
|
||||
* Target: `post` seals a reference into the inbox owner's native inbox
|
||||
* (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred
|
||||
* `@ng-eventually/service` package) MATERIALIZES the deposits into the owned
|
||||
* document. Here, in the shared-wallet polyfill (everything is readable),
|
||||
* both sides are emulated in-lib:
|
||||
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
||||
* DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
||||
* - `read` / `watch` play the CURATOR: they read the deposits back via
|
||||
* `docs.sparqlQuery` and expose them. This in-client emulation is enough
|
||||
* for the polyfill — at migration the real materialization moves to the
|
||||
* separate curator and this read side goes away.
|
||||
*
|
||||
* All NextGraph I/O routes through the T01.a `docs` primitives (the REAL
|
||||
* injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package.
|
||||
*/
|
||||
|
||||
import { getConfig, getCurrentUser } from "./polyfill";
|
||||
import type { Nuri } from "./types";
|
||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
|
||||
/**
|
||||
* Deposit a reference into a document's inbox (anticipated SDK shape). Target:
|
||||
* `ng.inbox_post_link(...)`, sealed to the inbox owner; `from` optional → the
|
||||
* sender is identified if known, anonymous otherwise. Polyfill: append a
|
||||
* deposit to the emulated inbox document.
|
||||
*/
|
||||
export async function post(
|
||||
targetInbox: Nuri,
|
||||
payload: unknown,
|
||||
opts?: { anonymous?: boolean },
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
const from = opts?.anonymous ? null : getCurrentUser();
|
||||
// TODO(polyfill): append { from, payload } to the emulated inbox document
|
||||
// (targetInbox) via ng.sparql_update.
|
||||
void ng;
|
||||
void from;
|
||||
void targetInbox;
|
||||
void payload;
|
||||
throw new Error("[ng-eventually] inbox.post: polyfill emulation not yet implemented");
|
||||
// --- deposit model --------------------------------------------------------
|
||||
|
||||
/** One deposit as materialized from an inbox document. */
|
||||
export interface Deposit {
|
||||
/** The sender, if identified; `null` when the deposit was anonymous. */
|
||||
from: PrincipalId | null;
|
||||
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
|
||||
payload: unknown;
|
||||
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** Options for {@link post}. `from` and `ts` are both optional. */
|
||||
export interface PostOptions {
|
||||
/**
|
||||
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
|
||||
* principal id to identify the sender. Defaults to the current polyfill user
|
||||
* ({@link getCurrentUser}) when the property is entirely absent, so callers
|
||||
* that want anonymity must pass `from: null` explicitly.
|
||||
*/
|
||||
from?: PrincipalId | null;
|
||||
/** The payload to deposit (interpreted only by the consumer). */
|
||||
payload: unknown;
|
||||
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
||||
* keeps tests deterministic. */
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
const SHIM = "urn:ng-eventually:inbox";
|
||||
const P = {
|
||||
type: `${SHIM}:Deposit`,
|
||||
from: `${SHIM}:from`,
|
||||
payload: `${SHIM}:payload`,
|
||||
ts: `${SHIM}:ts`,
|
||||
} as const;
|
||||
|
||||
// --- session access (shared with the storeRegistry) -----------------------
|
||||
|
||||
/** The inbox documents live in the shared wallet, so we reuse the registry's
|
||||
* injected session provider for the sessionId. Disappears at migration. */
|
||||
async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
||||
if (!result) return [];
|
||||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
||||
const anyRes = result as {
|
||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||||
};
|
||||
return anyRes.results?.bindings ?? [];
|
||||
}
|
||||
|
||||
// --- deposit (client side) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deposit a payload into `targetInbox`.
|
||||
*
|
||||
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
|
||||
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
|
||||
* graph, so concurrent deposits don't collide. `from` is optional: pass `null`
|
||||
* for an anonymous deposit; omit it entirely to default to the current user.
|
||||
*/
|
||||
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
|
||||
const from = opts.from === undefined ? getCurrentUser() : opts.from;
|
||||
const ts = opts.ts ?? Date.now();
|
||||
const sid = await sessionId();
|
||||
|
||||
// A unique subject per deposit (in the inbox graph) — no collisions.
|
||||
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
|
||||
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
|
||||
const fromTriple =
|
||||
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
||||
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${targetInbox}> {
|
||||
<${subject}> a <${P.type}> ;
|
||||
<${P.payload}> "${payloadLiteral}" ;
|
||||
<${P.ts}> "${ts}"${fromTriple} .
|
||||
}
|
||||
}`;
|
||||
await sparqlUpdate(sid, update, targetInbox);
|
||||
}
|
||||
|
||||
// --- read / materialize (emulated curator) --------------------------------
|
||||
|
||||
/**
|
||||
* Read (materialize) every deposit currently in `targetInbox`, sorted by `ts`
|
||||
* ascending. This is the EMULATED CURATOR: at migration a separate curator
|
||||
* process materializes deposits and this in-client read goes away. The consumer
|
||||
* interprets each deposit's `payload`.
|
||||
*/
|
||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
const sid = await sessionId();
|
||||
const query = `
|
||||
SELECT ?payload ?ts ?from WHERE {
|
||||
GRAPH <${targetInbox}> {
|
||||
?d a <${P.type}> ;
|
||||
<${P.payload}> ?payload ;
|
||||
<${P.ts}> ?ts .
|
||||
OPTIONAL { ?d <${P.from}> ?from }
|
||||
}
|
||||
}`;
|
||||
const result = await sparqlQuery(sid, query, undefined, targetInbox);
|
||||
const deposits: Deposit[] = [];
|
||||
for (const row of readBindings(result)) {
|
||||
const rawPayload = row.payload?.value ?? "null";
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch {
|
||||
payload = rawPayload; // tolerate a non-JSON literal
|
||||
}
|
||||
const tsRaw = row.ts?.value ?? "0";
|
||||
const ts = Number.parseInt(tsRaw, 10) || 0;
|
||||
const fromValue = row.from?.value;
|
||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
||||
}
|
||||
deposits.sort((a, b) => a.ts - b.ts);
|
||||
return deposits;
|
||||
}
|
||||
|
||||
/** Alias for {@link read} — the name that reads as "run the curator now". */
|
||||
export const materialize = read;
|
||||
|
||||
/**
|
||||
* Subscription over an inbox — the emulated watcher. Polls {@link read} on an
|
||||
* interval and invokes `onDeposits` with the full current deposit list whenever
|
||||
* it changes (grows). Returns an unsubscribe function. The polyfill has no
|
||||
* native reactive inbox subscription, so this emulates one; at migration it is
|
||||
* replaced by the real curator's watch. `onDeposits` fires once immediately.
|
||||
*/
|
||||
export function watch(
|
||||
targetInbox: Nuri,
|
||||
onDeposits: (deposits: Deposit[]) => void,
|
||||
opts?: { intervalMs?: number },
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let lastCount = -1;
|
||||
const intervalMs = opts?.intervalMs ?? 1000;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const deposits = await read(targetInbox);
|
||||
if (!stopped && deposits.length !== lastCount) {
|
||||
lastCount = deposits.length;
|
||||
onDeposits(deposits);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[inbox] watch read failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void tick(); // fire once immediately
|
||||
const handle = setInterval(() => void tick(), intervalMs);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ export type { Connections, IsolationAccessors } from "./isolation";
|
||||
export * as accounts from "./accounts";
|
||||
export type { AccountStorage } from "./accounts";
|
||||
|
||||
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
|
||||
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
|
||||
// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to
|
||||
// validate trusted-shaped NURIs before embedding them in an IRI.
|
||||
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
|
||||
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
|
||||
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
|
||||
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* surface stays identical to `@ng-org/web`'s `ng`.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
import { getConfig, getCaps, getCurrentUser } from "./polyfill";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
export function makeNg(): Record<string, any> {
|
||||
return new Proxy({} as Record<string, any>, {
|
||||
@@ -21,10 +22,28 @@ export function makeNg(): Record<string, any> {
|
||||
}
|
||||
|
||||
// sparql_update → write guard (emulated write-cap check).
|
||||
// Mirrors the target broker/verifier: a write is refused unless the wallet
|
||||
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
|
||||
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
|
||||
if (prop === "sparql_update") {
|
||||
return (...args: any[]) => {
|
||||
// TODO(polyfill): reject when getCurrentUser() lacks the write grant
|
||||
// on the target document (see access.canWrite). For now, passthrough.
|
||||
const anchor = args[2] as Nuri | undefined;
|
||||
const caps = getCaps();
|
||||
// Passthrough (no regression) unless a WRITE policy exists AND this
|
||||
// specific document is governed by it. Ungoverned docs (mono-store
|
||||
// default, no cap declared) flow through exactly as before.
|
||||
if (
|
||||
typeof anchor === "string" &&
|
||||
caps.hasWritePolicy() &&
|
||||
caps.governsWrite(anchor) &&
|
||||
!caps.canWrite(anchor, getCurrentUser())
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ng.sparql_update!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* SPARQL string-building safety helpers — shared by every module that builds
|
||||
* SPARQL by interpolation (inbox, store-registry).
|
||||
*
|
||||
* WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a
|
||||
* payload) is spliced verbatim into a query, a `"` closes a literal and a `>`
|
||||
* closes an IRI, letting the value inject arbitrary triples (or wreck the shim
|
||||
* graph, which is the trust root mapping accounts → document NURIs). Every
|
||||
* value that reaches a query MUST pass through one of these helpers first.
|
||||
*
|
||||
* TWO POSITIONS, TWO STRATEGIES:
|
||||
* - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than
|
||||
* reject because literals legitimately carry arbitrary text (JSON payloads,
|
||||
* display names). Escaping is lossless and reversible.
|
||||
* - IRI position (`<...>`): two cases.
|
||||
* · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate
|
||||
* with {@link assertNuri} — they should never contain IRI-breaking
|
||||
* chars; if one does, something upstream is wrong, so we throw rather
|
||||
* than silently build a broken/injected query.
|
||||
* · UNTRUSTED values embedded into an IRI (a username used to mint an
|
||||
* account-subject IRI): {@link escapeIri} percent-encodes every
|
||||
* IRI-hostile character. We *encode* rather than reject so any username
|
||||
* (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`,
|
||||
* whitespace and control chars can never break out of the IRI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a value for embedding inside a SPARQL string literal (`"..."`).
|
||||
* Escapes backslash, double-quote and the C0 whitespace controls that would
|
||||
* otherwise terminate or corrupt the literal. Lossless / reversible.
|
||||
*/
|
||||
export function escapeLiteral(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI
|
||||
* ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond
|
||||
* the space and all C0/C1 control characters are handled by the code-point
|
||||
* check in {@link isIriForbidden}. Any of these would let a value break out of
|
||||
* the `<...>` and inject arbitrary syntax.
|
||||
*/
|
||||
const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/;
|
||||
|
||||
/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */
|
||||
function isIriForbidden(ch: string): boolean {
|
||||
const code = ch.codePointAt(0)!;
|
||||
return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
|
||||
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
|
||||
* UNTRUSTED values (e.g. a username minted into an account-subject IRI):
|
||||
* encoding keeps every username usable while making breakout impossible.
|
||||
*
|
||||
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
|
||||
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
|
||||
* through unchanged and the resulting IRI stays human-readable.
|
||||
*/
|
||||
export function escapeIri(value: string): string {
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (isIriForbidden(ch)) {
|
||||
// Percent-encode each UTF-8 byte of the offending character. Also encode
|
||||
// the chars encodeURIComponent leaves alone but which are IRI-hostile.
|
||||
out += encodeURIComponent(ch).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
|
||||
);
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs
|
||||
* that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and
|
||||
* should never carry IRI-breaking characters; if one does, we throw rather than
|
||||
* emit a query that could be malformed or injected. Returns the value unchanged
|
||||
* so it can be used inline: `<${assertNuri(doc)}>`.
|
||||
*/
|
||||
export function assertNuri(nuri: string): string {
|
||||
if (typeof nuri !== "string" || nuri.length === 0) {
|
||||
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
|
||||
}
|
||||
for (const ch of nuri) {
|
||||
if (isIriForbidden(ch)) {
|
||||
throw new Error(
|
||||
`[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return nuri;
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getStoreRegistryDeps } from "./polyfill";
|
||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
@@ -61,7 +62,11 @@ const P = {
|
||||
const INDEX_SUBJECT = `${SHIM}:index`;
|
||||
|
||||
function accountSubject(username: string): string {
|
||||
return `${SHIM}:account:${normalize(username)}`;
|
||||
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||||
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
||||
// root). normalize() runs first so the subject stays stable per shim key.
|
||||
return `${SHIM}:account:${escapeIri(normalize(username))}`;
|
||||
}
|
||||
|
||||
// --- session / normalization access (injected by the consumer) ------------
|
||||
@@ -123,7 +128,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
const anchor = await anchorNuri();
|
||||
const query = `
|
||||
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
GRAPH <${anchor}> {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
?acc a <${P.type}> ;
|
||||
<${P.username}> ?username ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
@@ -184,14 +189,18 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const subj = accountSubject(username);
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
||||
// trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL
|
||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
||||
// literals here, so they are escaped as literals too (defence in depth).
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${anchor}> {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.username}> "${username}" ;
|
||||
<${P.docPublic}> "${docPublic}" ;
|
||||
<${P.docProtected}> "${docProtected}" ;
|
||||
<${P.docPrivate}> "${docPrivate}" .
|
||||
<${P.username}> "${escapeLiteral(username)}" ;
|
||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
@@ -246,7 +255,9 @@ export async function createEntityDoc(username: string, scope: Scope): Promise<N
|
||||
try {
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`,
|
||||
// indexDoc is a NURI in IRI position → assertNuri; entityNuri is a NURI
|
||||
// stored as a literal → escapeLiteral.
|
||||
`INSERT DATA { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" } }`,
|
||||
indexDoc,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -269,7 +280,7 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
|
||||
`SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user