Initial scaffold: @ng-eventually/client — SDK-shaped polyfill over NextGraph
Generic polyfill layer that makes a single NextGraph broker behave like the not-yet-shipped multi-user NextGraph (emulated capabilities + inbox). Zero app domain. @ng-eventually/client exposes an SDK-identical surface (ng, useShape, inbox); the polyfill bootstrap (configure + capability helpers) is isolated under /polyfill, so the main entry stays a drop-in for @ng-org/web|orm. The real SDK is injected at configure() (no hard import → build-alias safe + testable). Scaffold: NextGraph wiring stubbed with TODO; capability helpers implemented and unit-tested (4 tests, typecheck clean). The global-index curator is deferred — in NextGraph apps/services are mono-user with no global data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# @ng-eventually/client
|
||||
|
||||
Two entry points — the data-plane is **SDK-identical**, the polyfill bootstrap is
|
||||
separate:
|
||||
|
||||
| Import | Surface | At migration |
|
||||
|---|---|---|
|
||||
| `@ng-eventually/client` | **Same signature as the SDK** — `ng`, `useShape`, `inbox` (+ types). Drop-in for `@ng-org/web` / `@ng-org/orm`. | Resolves to the real SDK (build alias removed) — no code change. |
|
||||
| `@ng-eventually/client/polyfill` | The **only non-SDK** surface — `configure`, `setCurrentUser`, capability helpers (`canRead`/`canWrite`/`defaultGrant`/`grantRead`). | Removed. |
|
||||
|
||||
```ts
|
||||
// bootstrap (the only non-SDK call) — inject the REAL SDK
|
||||
import { configure } from "@ng-eventually/client/polyfill";
|
||||
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });
|
||||
|
||||
// from here on, pure SDK surface:
|
||||
import { ng, useShape, inbox } from "@ng-eventually/client";
|
||||
await ng.doc_create(/* … */);
|
||||
const set = useShape(MyShape, scope); // filtered to what the user may read
|
||||
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
|
||||
```
|
||||
|
||||
What the polyfill adds on top of the real SDK (all removed/native at migration):
|
||||
- **Shared-wallet login** (one wallet for everyone).
|
||||
- **Capability enforcement** — read filter + write guard, on emulated grants
|
||||
attached to documents.
|
||||
- **Anticipated methods** (inbox `post`, capability ops) with their future-SDK
|
||||
shapes, emulated for now.
|
||||
|
||||
Generic: **no application domain**. The consumer injects shapes and performs the
|
||||
*acts* of granting access. The client **must not** contain the global-index
|
||||
curator (a separate package, deferred — see the repo README).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@ng-eventually/client",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "SDK-identical client wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./polyfill": "./src/polyfill.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ng-org/web": "*",
|
||||
"@ng-org/orm": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ng-org/web": { "optional": true },
|
||||
"@ng-org/orm": { "optional": true }
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Capability emulation — pure, generic, no domain rules. Mirrors what the broker
|
||||
* will enforce via real caps; at migration these checks disappear (the broker
|
||||
* only ever syncs/serves what the wallet is authorized for).
|
||||
*/
|
||||
|
||||
import type { Grant, PrincipalId, Scope } from "./types";
|
||||
|
||||
/** May `principal` read a document carrying `grant`? */
|
||||
export function canRead(grant: Grant, principal: PrincipalId | null): boolean {
|
||||
if (grant.read.includes("public")) return true;
|
||||
if (principal === null) return false;
|
||||
return grant.read.includes(principal);
|
||||
}
|
||||
|
||||
/** May `principal` write a document carrying `grant`? */
|
||||
export function canWrite(grant: Grant, principal: PrincipalId | null): boolean {
|
||||
if (principal === null) return false;
|
||||
return grant.write.includes(principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant a freshly created document gets, by scope, for its `owner`. These
|
||||
* are the *defaults* a creator attaches; further sharing (e.g. granting a
|
||||
* connection read on a protected doc) is a separate explicit act.
|
||||
*/
|
||||
export function defaultGrant(scope: Scope, owner: PrincipalId): Grant {
|
||||
switch (scope) {
|
||||
case "public":
|
||||
return { read: ["public"], write: [owner] };
|
||||
case "protected":
|
||||
return { read: [owner], write: [owner] };
|
||||
case "private":
|
||||
return { read: [owner], write: [owner] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a read grant to a principal (e.g. when a connection is accepted). */
|
||||
export function grantRead(grant: Grant, principal: PrincipalId): Grant {
|
||||
if (grant.read.includes(principal)) return grant;
|
||||
return { ...grant, read: [...grant.read, principal] };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { getConfig, getCurrentUser } from "./polyfill";
|
||||
import type { Nuri } 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");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @ng-eventually/client — **SDK-identical** surface.
|
||||
*
|
||||
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
|
||||
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
|
||||
* during the polyfill period; at migration the build alias is removed and these
|
||||
* resolve to the real SDK with **no code change**.
|
||||
*
|
||||
* The one non-SDK piece — the polyfill bootstrap (`configure`, capability
|
||||
* helpers, current user) — lives at `@ng-eventually/client/polyfill`, and is the
|
||||
* only thing removed at migration.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export { useShape } from "./use-shape";
|
||||
export * as inbox from "./inbox";
|
||||
|
||||
import { makeNg } from "./ng-proxy";
|
||||
|
||||
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
|
||||
export const ng: Record<string, any> = makeNg();
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
|
||||
* overrides only what the broker/verifier will do natively at migration. The
|
||||
* surface stays identical to `@ng-org/web`'s `ng`.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
|
||||
export function makeNg(): Record<string, any> {
|
||||
return new Proxy({} as Record<string, any>, {
|
||||
get(_target, prop: string) {
|
||||
const { ng } = getConfig();
|
||||
|
||||
// login / session_start → open the SHARED wallet invisibly.
|
||||
if (prop === "login" || prop === "session_start") {
|
||||
return (...args: any[]) => {
|
||||
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
|
||||
// is shown. For now, passthrough.
|
||||
return ng[prop]!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// sparql_update → write guard (emulated write-cap check).
|
||||
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.
|
||||
return ng.sparql_update!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(anticipated API): inbox_post_link + capability operations — expose
|
||||
// here with their anticipated signatures, emulated for now.
|
||||
|
||||
// Everything else: passthrough to the real SDK, unchanged.
|
||||
const real = ng[prop];
|
||||
return typeof real === "function" ? real.bind(ng) : real;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The polyfill bootstrap — the ONLY non-SDK surface of the client.
|
||||
*
|
||||
* It injects the REAL SDK and the polyfill settings; afterwards the SDK-shaped
|
||||
* exports (`ng`, `useShape`, `inbox`) behave as drop-ins. This is exposed at the
|
||||
* subpath `@ng-eventually/client/polyfill` so the main entry
|
||||
* (`@ng-eventually/client`) stays a **pure, SDK-identical** surface. Everything
|
||||
* here is removed at migration.
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
||||
|
||||
export interface EventuallyConfig {
|
||||
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
|
||||
ng: NgLike;
|
||||
/** The REAL `@ng-org/orm` `useShape`. */
|
||||
useShape: UseShapeLike;
|
||||
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
|
||||
sharedWallet?: { name: string; secret: string };
|
||||
/** Initial current user; may also be set later via {@link setCurrentUser}. */
|
||||
currentUser?: PrincipalId;
|
||||
}
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
|
||||
export function configure(c: EventuallyConfig): void {
|
||||
cfg = c;
|
||||
currentUser = c.currentUser ?? null;
|
||||
}
|
||||
|
||||
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
|
||||
export function getConfig(): EventuallyConfig {
|
||||
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Set the current app-level user (the polyfill's notion of "who am I"). */
|
||||
export function setCurrentUser(id: PrincipalId | null): void {
|
||||
currentUser = id;
|
||||
}
|
||||
|
||||
export function getCurrentUser(): PrincipalId | null {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// Capability helpers — polyfill-era surface (caps are emulated now; native at
|
||||
// migration). Re-exported here so the whole polyfill API lives under /polyfill.
|
||||
export { canRead, canWrite, defaultGrant, grantRead } from "./access";
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
||||
*/
|
||||
|
||||
/** A NextGraph URI (document / store / inbox). */
|
||||
export type Nuri = string;
|
||||
|
||||
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
|
||||
* consumer's concern; this layer only knows the three scopes exist. */
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
|
||||
/** App-level current identity. Target: the wallet user (`session.user`).
|
||||
* Polyfill: a chosen id (e.g. a username), because everyone shares one wallet. */
|
||||
export type PrincipalId = string;
|
||||
|
||||
/**
|
||||
* Emulated capability attached to a document — mirrors real NextGraph caps,
|
||||
* which the broker/verifier will enforce natively at migration. The app attaches
|
||||
* these via cap operations exactly as it will in the target; this layer enforces
|
||||
* them generically (it does NOT know any authorization policy).
|
||||
*/
|
||||
export interface Grant {
|
||||
/** Read grant: `"public"` = everyone, otherwise explicit principals. */
|
||||
read: Array<PrincipalId | "public">;
|
||||
/** Write grant: principals allowed to write the document. */
|
||||
write: PrincipalId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
|
||||
* the consumer at {@link configure} — we never hard-import the SDK, which keeps
|
||||
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
|
||||
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
|
||||
* carries non-function members too, so we accept any property bag.
|
||||
*/
|
||||
export type NgLike = Record<string, any>;
|
||||
|
||||
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
|
||||
export type UseShapeLike = (...args: any[]) => any;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wrapped `useShape`: same signature as `@ng-org/orm`. The returned reactive set
|
||||
* is filtered to what the current user may read (emulated caps). At migration
|
||||
* this filtering disappears — the broker only syncs authorized documents.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const { useShape: real } = getConfig();
|
||||
const set = real(shapeType, scope);
|
||||
// TODO(polyfill): wrap `set` so iteration yields only entries whose emulated
|
||||
// grant satisfies access.canRead(grant, getCurrentUser()), while preserving
|
||||
// the reactivity of the underlying DeepSignalSet. This is the trickiest piece.
|
||||
return set;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { canRead, canWrite, defaultGrant, grantRead } from "../src/access";
|
||||
|
||||
test("public docs are readable by anyone, even anonymous", () => {
|
||||
const g = defaultGrant("public", "alice");
|
||||
expect(canRead(g, null)).toBe(true);
|
||||
expect(canRead(g, "bob")).toBe(true);
|
||||
});
|
||||
|
||||
test("protected docs: owner + explicitly granted principals only", () => {
|
||||
let g = defaultGrant("protected", "alice");
|
||||
expect(canRead(g, "alice")).toBe(true);
|
||||
expect(canRead(g, "bob")).toBe(false);
|
||||
g = grantRead(g, "bob"); // e.g. bob becomes a connection of alice
|
||||
expect(canRead(g, "bob")).toBe(true);
|
||||
});
|
||||
|
||||
test("private docs: owner only", () => {
|
||||
const g = defaultGrant("private", "alice");
|
||||
expect(canRead(g, "alice")).toBe(true);
|
||||
expect(canRead(g, "bob")).toBe(false);
|
||||
expect(canRead(g, null)).toBe(false);
|
||||
});
|
||||
|
||||
test("write is restricted to the write grant", () => {
|
||||
const g = defaultGrant("public", "alice");
|
||||
expect(canWrite(g, "alice")).toBe(true);
|
||||
expect(canWrite(g, "bob")).toBe(false);
|
||||
expect(canWrite(g, null)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user