Skip to content

Add an organization to your application

View Markdown

Your application serves many organizations. LBB has no customer object. The organization is your word for a namespace. The unit of work you give it, such as a project, a product line or an engagement, is a graph. See how to model your app for the three levels.

Adding an organization is a list of calls. You run them from your backend when the organization signs up. No call changes the server. Every step except step 2 is safe to send again.

Step Call What it makes
1 catalog.createNamespace The namespace of the organization.
2 createGraph One graph, with an empty ontology.
3 catalog.adoptGraph The graph becomes a member of the namespace.
4 ontology.starters.apply A base ontology on the graph.
5 files.upload or integrations.create The first data.
6 workflows.starters.use One workflow for each starter workflow you run.
7 access.createReadToken A short-lived token for the organization’s users.
  • Use an environment API key. Every call below needs it. Keep it on your server. It reads and writes every graph of the environment.
  • Check the catalog. Steps 1 and 3 need the application catalog. GET /version lists application_catalog_v1 when the server supports it. The catalog must also be enabled on the deployment.
  • Check read tokens. Step 7 needs scoped_graph_reads_v1 and the access feature, which is a separate gate. Skip step 7 until your operator enables it.
  • Check workflows. Step 6 needs the workflow engine. On a local server, run it with --workflows.

Graph ids have 1 to 32 lowercase letters, digits, - or _. Derive one from your own ids, so that names and addresses stay out of storage keys and logs.

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { LbbClient } from "@littlebigbrain/client";
const admin = new LbbClient({
baseUrl: process.env.LBB_URL!,
apiKey: process.env.LBB_API_KEY!,
});
const organization = { id: "42", name: "acme" };
const unit = { id: "7" };
const graphId = `u-${createHash("sha256")
.update(`${organization.id}/${unit.id}`)
.digest("hex")
.slice(0, 24)}`;
import hashlib
import os
import httpx
from lbb import LbbClient
url = os.environ["LBB_URL"]
api_key = os.environ["LBB_API_KEY"]
admin = LbbClient(url, api_key=api_key)
organization = {"id": "42", "name": "acme"}
unit = {"id": "7"}
graph_id = "u-" + hashlib.sha256(
f"{organization['id']}/{unit['id']}".encode()
).hexdigest()[:24]

A namespace name has 1 to 64 lowercase letters, digits, - or _. external_key is your own id for the organization. It is unique in the environment, so you can find the namespace by it later. The idempotency key makes a retry safe after a lost reply. Send the same key with the same request.

const namespace = await admin.catalog.createNamespace(
{ name: organization.name, external_key: `organization:${organization.id}` },
{ idempotencyKey: `namespace-${organization.id}` },
);
const namespaceId = namespace.identity.id;
namespace = admin.catalog.create_namespace(
{"name": organization["name"], "external_key": f"organization:{organization['id']}"},
idempotency_key=f"namespace-{organization['id']}",
)
namespace_id = namespace["identity"]["id"]

A namespace can hold other namespaces, up to 16 levels with the root. Pass parent_id to create a level below one, for example projects below acme. catalog.resolve("acme/projects") finds a namespace by its path.

const project = admin.withScope({ graph: graphId });
await project.createGraph();
project = LbbClient(url, api_key=api_key, graph=graph_id)
project.create_graph()

The graph starts with an empty ontology. Run this step once for each unit of work. A unit that starts from an earlier one can use POST /v1/graph/fork instead, which copies a graph in the same environment.

Adoption names the namespace as the owner of the graph. It needs the exact epoch of the graph, as a decimal string. A stale epoch fails, so a recreated graph is never adopted by mistake. The graph id and its history do not change.

const scope = await project.request<{ epoch: string }>("GET", "/v1/workflows/scope");
await project.catalog.adoptGraph(
{
owner_id: namespaceId,
expected_graph_epoch: scope.epoch,
external_key: `unit:${unit.id}`,
},
{ idempotencyKey: `adopt-${graphId}` },
);
scope = httpx.get(
f"{url}/v1/workflows/scope",
params={"graph": graph_id},
headers={"Authorization": f"Bearer {api_key}"},
).json()
project.catalog.adopt_graph(
{
"owner_id": namespace_id,
"expected_graph_epoch": scope["epoch"],
"external_key": f"unit:{unit['id']}",
},
idempotency_key=f"adopt-{graph_id}",
)

Use a client without the client-wide expectedGraphEpoch option for the catalog calls. Adoption is permanent in this release. A namespace and a binding cannot be moved or removed.

An ontology starter is a versioned base for CRM, documents or work management. Preview first. The preview writes nothing.

const starters = admin.graph(graphId).ontology.starters;
const preview = await starters.apply("documents", { dryRun: true });
console.log(preview.applied_ops.length, preview.ontology_version);
const applied = await starters.apply("documents");
console.log(applied.status.state); // "applied"
starters = admin.graph(graph_id).ontology.starters
preview = starters.apply("documents", dry_run=True)
applied = starters.apply("documents")
print(applied.status.state.value) # "applied"

Applying again changes nothing. A term the graph holds differently makes the call refuse with 409 starter_conflict, and nothing is written. The organization’s own classes and fields land as extensions beside the base.

Choose the one that fits the unit of work.

Upload a file. The server stores the bytes once under their SHA-256 and adds a Document record. The same bytes under the same name change nothing.

const bytes = await readFile("manual.pdf");
const { file } = await project.files.upload(bytes, {
name: "manual.pdf",
contentType: "application/pdf",
});
console.log(file.sha256);
answer = project.files.upload("manual.pdf")
print(answer["file"]["sha256"])

Connect a source. integrations.create seals the credential, creates the graph when it does not exist, applies the connector’s starter, checks the fit and starts the first sync. For a source, send it in place of steps 2 and 4, then adopt the graph as in step 3. It needs developer access on the environment. See integrations for your customers.

await admin.integrations.create({
graph: graphId,
id: "hubspot",
kind: "hubspot",
credentials: { HUBSPOT_TOKEN: process.env.HUBSPOT_TOKEN! },
});
admin.integrations.create(
graph=graph_id,
id="hubspot",
kind="hubspot",
credentials={"HUBSPOT_TOKEN": os.environ["HUBSPOT_TOKEN"]},
)

A workflow belongs to one graph. Create each one from its starter workflow. Keep the list in your code, and run the same list for every new graph.

const workflows = admin.graph(graphId).workflows;
await workflows.starters.use({ starter: "documents.parse" });
await workflows.starters.use({ starter: "documents.link", gate: { below: 0.8 } });
project.use_workflow_starter("documents.parse")
project.use_workflow_starter("documents.link", gate=0.8)

The same request again changes nothing, so a retry is safe. A workflow reads every existing instance first, then follows each published commit. See from documents to a graph for the whole chain and workflows for workflows that run in your worker.

Your backend issues the token after it has checked the signed-in user. The token grants SPARQL reads on the namespace and on every graph adopted below it later. It lives for 1 to 900 seconds. It cannot write, read files or start workflows.

const credential = await admin.access.createReadToken({
grants: [{ kind: "namespace", namespace_id: namespaceId }],
ttl_seconds: 300,
});
console.log(credential.expires_at_ms);
credential = admin.access.create_read_token(
{
"grants": [{"kind": "namespace", "namespace_id": namespace_id}],
"ttl_seconds": 300,
}
)

Give credential.token to the user’s session. It is a bearer credential on the standard SPARQL route:

Terminal window
curl -X POST "$LBB_URL/sparql?graph=$GRAPH_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/sparql-query' \
-H 'Accept: application/sparql-results+json' \
--data 'SELECT ?name WHERE { ?d <https://littlebigbrain.com/p/name> ?name } LIMIT 10'

Revoke a token early with access.revokeToken(credential.credential_id). See application read tokens for the routes a token can use.

Terminal window
curl "$LBB_URL/v1/catalog/graphs?owner_id=$NAMESPACE_ID" \
-H "Authorization: Bearer $LBB_API_KEY"
curl "$LBB_URL/v1/ontology/starters?graph=$GRAPH_ID" \
-H "Authorization: Bearer $LBB_API_KEY"
curl "$LBB_URL/v1/files/counts?graph=$GRAPH_ID" \
-H "Authorization: Bearer $LBB_API_KEY"
curl "$LBB_URL/v1/workflows/triggered?graph=$GRAPH_ID" \
-H "Authorization: Bearer $LBB_API_KEY"

The first call lists the graphs of the namespace. The others show the starter status, the files by parse status, and the workflows with their state.

An interrupted adoption finishes when you send the same idempotency key with the same request again. catalog.pendingOperations() lists the ones that are still open.

  • Removing an organization. A namespace cannot be deleted, renamed or moved in this release. Deleting a graph (POST /v1/graph/delete?graph=<id>&confirm=<id>) stops reads through its binding. The namespace stays.
  • A closed graph. No call closes a graph for writes while it stays readable.
  • One call for all steps. Each step above is its own call.

Read the graphs of the organization together with a namespace query.