langgraph-tenancy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abhishek Chauhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # langgraph-tenancy
2
+
3
+ [![CI](https://github.com/ac12644/langgraph-tenancy-js/actions/workflows/ci.yml/badge.svg)](https://github.com/ac12644/langgraph-tenancy-js/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/langgraph-tenancy.svg)](https://www.npmjs.com/package/langgraph-tenancy)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ **Tenant isolation for LangGraph.js persistence — as a drop-in wrapper.**
8
+
9
+ > Python version: [langgraph-tenancy on PyPI](https://pypi.org/project/langgraph-tenancy/)
10
+
11
+ LangGraph's own [threat model](https://github.com/langchain-ai/langgraph/blob/main/.github/THREAT_MODEL.md) says it plainly:
12
+
13
+ > Checkpoint savers index by `thread_id`. Without application-level auth, any
14
+ > caller with a valid thread_id can access that thread's state. [...] Users
15
+ > embedding LangGraph directly must implement their own access controls.
16
+
17
+ If you run a multi-tenant product on open-source LangGraph.js, the only thing
18
+ between Customer A's agent state and Customer B's is a query filter in your
19
+ application code. This package replaces that convention with enforcement.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install langgraph-tenancy
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Wrap your existing checkpointer and store. `tenant_id` becomes required.
30
+
31
+ ```ts
32
+ import {
33
+ TenantScopedCheckpointer,
34
+ TenantScopedStore,
35
+ getTenantStore,
36
+ InMemoryUsageLedger,
37
+ } from "langgraph-tenancy";
38
+
39
+ const ledger = new InMemoryUsageLedger();
40
+ const checkpointer = new TenantScopedCheckpointer(new PostgresSaver(...), {
41
+ usageLedger: ledger,
42
+ });
43
+ const store = new TenantScopedStore(new InMemoryStore());
44
+
45
+ const graph = builder.compile({ checkpointer, store });
46
+
47
+ await graph.invoke(input, {
48
+ configurable: { thread_id: "t1", tenant_id: "acme" },
49
+ });
50
+
51
+ // free per-tenant token metering, extracted from checkpointed messages
52
+ ledger.totals("acme"); // { inputTokens, outputTokens, totalTokens, byModel }
53
+ ```
54
+
55
+ Inside nodes, access the store through `getTenantStore(config)`:
56
+
57
+ ```ts
58
+ const myNode = async (state, config) => {
59
+ const store = getTenantStore(config); // reads tenant_id from the run config
60
+ await store.put(["memories"], "k", { note: "..." });
61
+ const items = await store.search(["memories"]);
62
+ // namespaces in results come back unprefixed — the tenant never leaks out
63
+ };
64
+ ```
65
+
66
+ Outside a run (admin scripts, background jobs):
67
+
68
+ ```ts
69
+ const acme = store.forTenant("acme");
70
+ await acme.search(["memories"]);
71
+ await checkpointer.forTenant("acme").deleteThread("t1");
72
+ ```
73
+
74
+ ## What it enforces
75
+
76
+ | Raw LangGraph.js behavior | With `langgraph-tenancy` |
77
+ |---|---|
78
+ | Any caller with a `thread_id` reads that thread | Threads are physically keyed `tenant::thread`; wrong-thread_id bugs cannot cross tenants |
79
+ | Missing filter → silent unscoped query | Missing `tenant_id` → `TenantRequiredError`, nothing read or written |
80
+ | `saver.list({})` enumerates **every** tenant's threads | Refused — a tenant is mandatory before anything is read |
81
+ | Store namespaces are convention; any node can read any namespace | Every operation must go through a tenant-scoped entry point |
82
+ | Raw `config.store.put(...)` in a node writes unscoped | **Fails closed** with `UnscopedAccessError` — it cannot silently leak |
83
+ | `deleteThread("t1")` deletes whoever owns `t1` | Requires an explicit `forTenant("acme").deleteThread("t1")` handle |
84
+ | `usage_metadata` buried in checkpoint blobs, unqueryable | Aggregated per tenant (and per model), deduped by message id |
85
+
86
+ ## Why the store API differs from the Python package
87
+
88
+ In Python, the store wrapper resolves the tenant ambiently from the run
89
+ config on every call. That design cannot work in LangGraph.js: the compiled
90
+ graph wraps your store in an `AsyncBatchedStore` whose background queue
91
+ processes operations **outside** the run's `AsyncLocalStorage` context, so
92
+ the run config is unavailable by the time operations reach the wrapped store.
93
+
94
+ Instead, scoping happens at the call site, where the config *is* available
95
+ (`getTenantStore(config)` in nodes, `forTenant()` outside runs), and
96
+ `TenantScopedStore` **refuses any operation that did not go through a scoped
97
+ entry point**. The guarantee is the same — unscoped access is impossible, not
98
+ merely discouraged — the entry point is just explicit.
99
+
100
+ ## No magic
101
+
102
+ The entire mechanism is key prefixing plus mandatory-context checks:
103
+
104
+ - thread ids become `"{tenant_id}::{thread_id}"` before reaching your
105
+ database; the prefix is stripped from everything returned.
106
+ - store namespaces `["memories"]` become `["{tenant_id}", "memories"]`.
107
+ - tenant ids containing the separator are rejected, so `acme` can never craft
108
+ a key that collides with another tenant's space.
109
+
110
+ It composes with any `BaseCheckpointSaver` / `BaseStore` implementation —
111
+ Postgres, SQLite, MongoDB, Redis, in-memory — because it never touches
112
+ storage itself.
113
+
114
+ ## What it is not
115
+
116
+ - Not authentication. You decide which tenant a request belongs to; this
117
+ package guarantees that decision is enforced everywhere downstream.
118
+ - Not a replacement for database-level controls in high-assurance setups
119
+ (RLS, schema-per-tenant) — it's the layer that makes your *application*
120
+ unable to leak, whatever the database allows.
121
+
122
+ ## Tested
123
+
124
+ The adversarial test suite — every test attempts a cross-tenant access the
125
+ raw LangGraph.js API allows — runs against the real `MemorySaver` and
126
+ `InMemoryStore` in CI, including a test that proves raw `config.store`
127
+ access inside a node fails closed.
128
+
129
+ ## Status
130
+
131
+ Early (0.1.x). Covered today: checkpointer paths, store paths via
132
+ `getTenantStore`/`forTenant`, in-memory backends. Not yet covered:
133
+ `PostgresSaver` integration tests, subgraph `checkpoint_ns` edge cases,
134
+ semantic search (`index`/`query`) pass-through. Issues and PRs welcome.
135
+
136
+ ## License
137
+
138
+ [MIT](LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ InMemoryUsageLedger: () => InMemoryUsageLedger,
24
+ InvalidTenantError: () => InvalidTenantError,
25
+ SEP: () => SEP,
26
+ TENANT_NS_SENTINEL: () => TENANT_NS_SENTINEL,
27
+ TenancyError: () => TenancyError,
28
+ TenantCheckpointerHandle: () => TenantCheckpointerHandle,
29
+ TenantRequiredError: () => TenantRequiredError,
30
+ TenantScopedCheckpointer: () => TenantScopedCheckpointer,
31
+ TenantScopedStore: () => TenantScopedStore,
32
+ TenantStoreView: () => TenantStoreView,
33
+ UnscopedAccessError: () => UnscopedAccessError,
34
+ extractUsage: () => extractUsage,
35
+ getTenantStore: () => getTenantStore,
36
+ validateTenant: () => validateTenant
37
+ });
38
+ module.exports = __toCommonJS(index_exports);
39
+
40
+ // src/checkpointer.ts
41
+ var import_langgraph_checkpoint = require("@langchain/langgraph-checkpoint");
42
+
43
+ // src/errors.ts
44
+ var TenancyError = class extends Error {
45
+ name = "TenancyError";
46
+ };
47
+ var TenantRequiredError = class extends TenancyError {
48
+ name = "TenantRequiredError";
49
+ constructor(where) {
50
+ super(
51
+ `${where} requires a tenant. Pass it in the run config: { configurable: { thread_id, tenant_id } }, use getTenantStore(config) inside nodes, or .forTenant(tenantId) for out-of-band access.`
52
+ );
53
+ }
54
+ };
55
+ var UnscopedAccessError = class extends TenancyError {
56
+ name = "UnscopedAccessError";
57
+ };
58
+ var InvalidTenantError = class extends TenancyError {
59
+ name = "InvalidTenantError";
60
+ };
61
+
62
+ // src/tenant.ts
63
+ var SEP = "::";
64
+ function validateTenant(tenant, where) {
65
+ if (typeof tenant !== "string" || tenant.length === 0) {
66
+ throw new TenantRequiredError(where);
67
+ }
68
+ if (tenant.includes(SEP)) {
69
+ throw new InvalidTenantError(
70
+ `tenant_id may not contain '${SEP}': ${JSON.stringify(tenant)}`
71
+ );
72
+ }
73
+ return tenant;
74
+ }
75
+
76
+ // src/usage.ts
77
+ var emptyUsage = () => ({
78
+ inputTokens: 0,
79
+ outputTokens: 0,
80
+ totalTokens: 0,
81
+ messages: 0,
82
+ byModel: {}
83
+ });
84
+ var InMemoryUsageLedger = class {
85
+ seen = /* @__PURE__ */ new Set();
86
+ byTenant = /* @__PURE__ */ new Map();
87
+ record(tenantId, record) {
88
+ const key = `${tenantId}\0${record.messageId}`;
89
+ if (this.seen.has(key)) return;
90
+ this.seen.add(key);
91
+ let usage = this.byTenant.get(tenantId);
92
+ if (!usage) {
93
+ usage = emptyUsage();
94
+ this.byTenant.set(tenantId, usage);
95
+ }
96
+ usage.inputTokens += record.inputTokens;
97
+ usage.outputTokens += record.outputTokens;
98
+ usage.totalTokens += record.totalTokens;
99
+ usage.messages += 1;
100
+ if (record.model) {
101
+ usage.byModel[record.model] = (usage.byModel[record.model] ?? 0) + record.totalTokens;
102
+ }
103
+ }
104
+ totals(tenantId) {
105
+ return this.byTenant.get(tenantId) ?? emptyUsage();
106
+ }
107
+ };
108
+ function extractUsage(checkpoint) {
109
+ const records = [];
110
+ for (const value of Object.values(checkpoint.channel_values ?? {})) {
111
+ const items = Array.isArray(value) ? value : [value];
112
+ for (const item of items) {
113
+ const message = item;
114
+ const usage = message?.usage_metadata;
115
+ const id = message?.id;
116
+ if (!usage || typeof id !== "string") continue;
117
+ records.push({
118
+ messageId: id,
119
+ model: message?.response_metadata?.model_name,
120
+ inputTokens: usage.input_tokens ?? 0,
121
+ outputTokens: usage.output_tokens ?? 0,
122
+ totalTokens: usage.total_tokens ?? 0
123
+ });
124
+ }
125
+ }
126
+ return records;
127
+ }
128
+
129
+ // src/checkpointer.ts
130
+ var TenantScopedCheckpointer = class extends import_langgraph_checkpoint.BaseCheckpointSaver {
131
+ inner;
132
+ usageLedger;
133
+ constructor(inner, options) {
134
+ super(inner.serde);
135
+ this.inner = inner;
136
+ this.usageLedger = options?.usageLedger;
137
+ }
138
+ scope(config, where) {
139
+ const conf = config?.configurable ?? {};
140
+ const tenant = validateTenant(conf.tenant_id, where);
141
+ return [
142
+ tenant,
143
+ {
144
+ ...config,
145
+ configurable: { ...conf, thread_id: `${tenant}${SEP}${conf.thread_id}` }
146
+ }
147
+ ];
148
+ }
149
+ unscopeConfig(tenant, config) {
150
+ if (!config) return config;
151
+ const conf = { ...config.configurable ?? {} };
152
+ const threadId = conf.thread_id;
153
+ const prefix = `${tenant}${SEP}`;
154
+ if (typeof threadId === "string" && threadId.startsWith(prefix)) {
155
+ conf.thread_id = threadId.slice(prefix.length);
156
+ conf.tenant_id = tenant;
157
+ }
158
+ return { ...config, configurable: conf };
159
+ }
160
+ unscopeTuple(tenant, tuple) {
161
+ if (!tuple) return tuple;
162
+ return {
163
+ ...tuple,
164
+ config: this.unscopeConfig(tenant, tuple.config),
165
+ parentConfig: this.unscopeConfig(tenant, tuple.parentConfig)
166
+ };
167
+ }
168
+ async getTuple(config) {
169
+ const [tenant, scoped] = this.scope(config, "getTuple()");
170
+ return this.unscopeTuple(tenant, await this.inner.getTuple(scoped));
171
+ }
172
+ async *list(config, options) {
173
+ const [tenant, scoped] = this.scope(config ?? {}, "list()");
174
+ const scopedOptions = options?.before ? { ...options, before: this.scope(options.before, "list({before})")[1] } : options;
175
+ for await (const tuple of this.inner.list(scoped, scopedOptions)) {
176
+ yield this.unscopeTuple(tenant, tuple);
177
+ }
178
+ }
179
+ async put(config, checkpoint, metadata, newVersions) {
180
+ const [tenant, scoped] = this.scope(config, "put()");
181
+ if (this.usageLedger) {
182
+ for (const record of extractUsage(checkpoint)) {
183
+ this.usageLedger.record(tenant, record);
184
+ }
185
+ }
186
+ return this.unscopeConfig(
187
+ tenant,
188
+ await this.inner.put(scoped, checkpoint, metadata, newVersions)
189
+ );
190
+ }
191
+ async putWrites(config, writes, taskId) {
192
+ const [, scoped] = this.scope(config, "putWrites()");
193
+ return this.inner.putWrites(scoped, writes, taskId);
194
+ }
195
+ async deleteThread(_threadId) {
196
+ throw new UnscopedAccessError(
197
+ "deleteThread() has no tenant context; use forTenant(tenantId).deleteThread(threadId)."
198
+ );
199
+ }
200
+ getNextVersion(current) {
201
+ return this.inner.getNextVersion(current);
202
+ }
203
+ /** Admin/maintenance handle pinned to one tenant. */
204
+ forTenant(tenantId) {
205
+ return new TenantCheckpointerHandle(
206
+ this.inner,
207
+ validateTenant(tenantId, "forTenant()")
208
+ );
209
+ }
210
+ };
211
+ var TenantCheckpointerHandle = class {
212
+ constructor(inner, tenant) {
213
+ this.inner = inner;
214
+ this.tenant = tenant;
215
+ }
216
+ inner;
217
+ tenant;
218
+ async deleteThread(threadId) {
219
+ return this.inner.deleteThread(`${this.tenant}${SEP}${threadId}`);
220
+ }
221
+ };
222
+
223
+ // src/store.ts
224
+ var import_langgraph_checkpoint2 = require("@langchain/langgraph-checkpoint");
225
+ var TENANT_NS_SENTINEL = "~tenant~";
226
+ var UNSCOPED_MESSAGE = "Store accessed without tenant scoping. Use getTenantStore(config) inside nodes, or store.forTenant(tenantId) outside runs. Raw store access is refused so it cannot silently read or write across tenants.";
227
+ var TenantScopedStore = class extends import_langgraph_checkpoint2.BaseStore {
228
+ inner;
229
+ constructor(inner) {
230
+ super();
231
+ this.inner = inner;
232
+ }
233
+ /** A view of the store pinned to one tenant, for use outside a run. */
234
+ forTenant(tenantId) {
235
+ return new TenantStoreView(this, validateTenant(tenantId, "forTenant()"));
236
+ }
237
+ async batch(operations) {
238
+ const scoped = operations.map((op) => this.requireScoped(op));
239
+ return this.inner.batch(scoped);
240
+ }
241
+ requireScoped(op) {
242
+ if ("namespacePrefix" in op) {
243
+ this.assertSentinel(op.namespacePrefix);
244
+ return { ...op, namespacePrefix: op.namespacePrefix.slice(1) };
245
+ }
246
+ if ("namespace" in op) {
247
+ this.assertSentinel(op.namespace);
248
+ return { ...op, namespace: op.namespace.slice(1) };
249
+ }
250
+ const conditions = op.matchConditions ?? [];
251
+ const prefixIndex = conditions.findIndex((c) => c.matchType === "prefix");
252
+ if (prefixIndex < 0) throw new UnscopedAccessError(UNSCOPED_MESSAGE);
253
+ this.assertSentinel(conditions[prefixIndex].path);
254
+ const next = conditions.map(
255
+ (c, i) => i === prefixIndex ? { ...c, path: c.path.slice(1) } : c
256
+ );
257
+ return { ...op, matchConditions: next };
258
+ }
259
+ assertSentinel(namespace) {
260
+ if (namespace[0] !== TENANT_NS_SENTINEL || typeof namespace[1] !== "string" || namespace[1].length === 0) {
261
+ throw new UnscopedAccessError(UNSCOPED_MESSAGE);
262
+ }
263
+ }
264
+ async start() {
265
+ await this.inner.start();
266
+ }
267
+ async stop() {
268
+ await this.inner.stop();
269
+ }
270
+ };
271
+ var TenantStoreView = class {
272
+ constructor(target, tenant) {
273
+ this.target = target;
274
+ this.tenant = tenant;
275
+ }
276
+ target;
277
+ tenant;
278
+ scopeNs(namespace) {
279
+ return [TENANT_NS_SENTINEL, this.tenant, ...namespace];
280
+ }
281
+ unscopeItem(item) {
282
+ if (item) item.namespace = item.namespace.slice(1);
283
+ return item;
284
+ }
285
+ async get(namespace, key) {
286
+ return this.unscopeItem(await this.target.get(this.scopeNs(namespace), key));
287
+ }
288
+ async search(namespacePrefix, options) {
289
+ const items = await this.target.search(
290
+ this.scopeNs(namespacePrefix),
291
+ options
292
+ );
293
+ for (const item of items) this.unscopeItem(item);
294
+ return items;
295
+ }
296
+ async put(namespace, key, value) {
297
+ return this.target.put(this.scopeNs(namespace), key, value);
298
+ }
299
+ async delete(namespace, key) {
300
+ return this.target.delete(this.scopeNs(namespace), key);
301
+ }
302
+ async listNamespaces(options = {}) {
303
+ if (!this.target.listNamespaces) {
304
+ throw new TenancyError(
305
+ "listNamespaces is not available through config.store (LangGraph batches in-graph store access); call it on store.forTenant(tenantId) instead."
306
+ );
307
+ }
308
+ const results = await this.target.listNamespaces({
309
+ ...options,
310
+ prefix: this.scopeNs(options.prefix ?? []),
311
+ maxDepth: options.maxDepth == null ? void 0 : options.maxDepth + 1
312
+ });
313
+ return results.filter((ns) => ns[0] === this.tenant).map((ns) => ns.slice(1));
314
+ }
315
+ };
316
+ function getTenantStore(config) {
317
+ const tenant = validateTenant(
318
+ config?.configurable?.tenant_id,
319
+ "getTenantStore()"
320
+ );
321
+ if (!config.store) {
322
+ throw new TenancyError(
323
+ "getTenantStore(config): config.store is missing \u2014 was the graph compiled with a store?"
324
+ );
325
+ }
326
+ return new TenantStoreView(config.store, tenant);
327
+ }
328
+ // Annotate the CommonJS export names for ESM import in node:
329
+ 0 && (module.exports = {
330
+ InMemoryUsageLedger,
331
+ InvalidTenantError,
332
+ SEP,
333
+ TENANT_NS_SENTINEL,
334
+ TenancyError,
335
+ TenantCheckpointerHandle,
336
+ TenantRequiredError,
337
+ TenantScopedCheckpointer,
338
+ TenantScopedStore,
339
+ TenantStoreView,
340
+ UnscopedAccessError,
341
+ extractUsage,
342
+ getTenantStore,
343
+ validateTenant
344
+ });
@@ -0,0 +1,226 @@
1
+ import { RunnableConfig } from '@langchain/core/runnables';
2
+ import { BaseCheckpointSaver, CheckpointTuple, CheckpointListOptions, Checkpoint, CheckpointMetadata, ChannelVersions, PendingWrite, BaseStore, Item, SearchItem, Operation, OperationResults } from '@langchain/langgraph-checkpoint';
3
+
4
+ /**
5
+ * Per-tenant token usage, extracted at the checkpoint boundary.
6
+ *
7
+ * LangGraph already persists `usage_metadata` on every AI message inside
8
+ * `checkpoint.channel_values` — it is just never indexed or aggregated.
9
+ * Since the tenant-scoped checkpointer sees every checkpoint anyway, it can
10
+ * pull usage out and attribute it to the tenant for free.
11
+ *
12
+ * Messages are deduplicated by message id, so re-checkpointing the same
13
+ * conversation does not double-count.
14
+ */
15
+ interface UsageRecord {
16
+ messageId: string;
17
+ model?: string;
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ totalTokens: number;
21
+ }
22
+ /**
23
+ * Anything that can receive per-tenant usage records. Swap the in-memory
24
+ * default for one backed by your own database, StatsD, OpenMeter, etc.
25
+ */
26
+ interface UsageLedger {
27
+ record(tenantId: string, record: UsageRecord): void;
28
+ }
29
+ interface TenantUsage {
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ totalTokens: number;
33
+ messages: number;
34
+ byModel: Record<string, number>;
35
+ }
36
+ /** Reference ledger: per-tenant totals, deduped by message id. */
37
+ declare class InMemoryUsageLedger implements UsageLedger {
38
+ private seen;
39
+ private byTenant;
40
+ record(tenantId: string, record: UsageRecord): void;
41
+ totals(tenantId: string): TenantUsage;
42
+ }
43
+ /** Pull usage records out of message objects in a checkpoint's channels. */
44
+ declare function extractUsage(checkpoint: {
45
+ channel_values?: Record<string, unknown>;
46
+ }): UsageRecord[];
47
+
48
+ /**
49
+ * Tenant-scoped wrapper around any `BaseCheckpointSaver`.
50
+ *
51
+ * - Reads `tenant_id` from `config.configurable` on every call. Missing
52
+ * tenant -> `TenantRequiredError`. There is no unscoped fallback.
53
+ * - Physically prefixes `thread_id` with the tenant (`acme::thread-1`) before
54
+ * it reaches the inner saver, and strips the prefix from everything
55
+ * returned. A wrong-thread_id bug in app code therefore cannot cross a
56
+ * tenant boundary: the key the database sees is always tenant-qualified.
57
+ * - Blocks the dangerous raw-API escape hatch: `deleteThread()` takes a bare
58
+ * thread id with no config, so it is refused and redirected to an explicit
59
+ * `forTenant()` handle.
60
+ * - Optionally records per-tenant token usage from checkpointed messages
61
+ * into a `UsageLedger` — same integration point, free metering.
62
+ */
63
+
64
+ declare class TenantScopedCheckpointer extends BaseCheckpointSaver {
65
+ readonly inner: BaseCheckpointSaver;
66
+ readonly usageLedger?: UsageLedger;
67
+ constructor(inner: BaseCheckpointSaver, options?: {
68
+ usageLedger?: UsageLedger;
69
+ });
70
+ private scope;
71
+ private unscopeConfig;
72
+ private unscopeTuple;
73
+ getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined>;
74
+ list(config: RunnableConfig, options?: CheckpointListOptions): AsyncGenerator<CheckpointTuple>;
75
+ put(config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, newVersions: ChannelVersions): Promise<RunnableConfig>;
76
+ putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise<void>;
77
+ deleteThread(_threadId: string): Promise<void>;
78
+ getNextVersion(current: number | undefined): number;
79
+ /** Admin/maintenance handle pinned to one tenant. */
80
+ forTenant(tenantId: string): TenantCheckpointerHandle;
81
+ }
82
+ /**
83
+ * Maintenance operations pre-bound to a single tenant. Exists because
84
+ * `deleteThread` takes a bare thread id with no config, so there is no
85
+ * per-call tenant to read.
86
+ */
87
+ declare class TenantCheckpointerHandle {
88
+ private readonly inner;
89
+ private readonly tenant;
90
+ constructor(inner: BaseCheckpointSaver, tenant: string);
91
+ deleteThread(threadId: string): Promise<void>;
92
+ }
93
+
94
+ /**
95
+ * Errors raised by langgraph-tenancy. Every error here exists to turn a
96
+ * silent cross-tenant leak into a loud failure.
97
+ */
98
+ declare class TenancyError extends Error {
99
+ name: string;
100
+ }
101
+ /**
102
+ * An operation ran without a tenant in scope. There is no unscoped fallback:
103
+ * no tenant, no data.
104
+ */
105
+ declare class TenantRequiredError extends TenancyError {
106
+ name: string;
107
+ constructor(where: string);
108
+ }
109
+ /**
110
+ * An operation would touch data across tenant boundaries (e.g. a store call
111
+ * that bypassed tenant scoping, or deleteThread without a tenant handle).
112
+ */
113
+ declare class UnscopedAccessError extends TenancyError {
114
+ name: string;
115
+ }
116
+ /**
117
+ * A tenant id that could be used to escape its scope. Tenant ids become part
118
+ * of storage keys, so they must not contain the separator or be empty.
119
+ */
120
+ declare class InvalidTenantError extends TenancyError {
121
+ name: string;
122
+ }
123
+
124
+ /**
125
+ * Tenant-scoped wrapper around any `BaseStore`.
126
+ *
127
+ * Raw `BaseStore` namespaces are pure convention — any caller can `get()`,
128
+ * `search()`, or `listNamespaces()` across all of them. This wrapper roots
129
+ * every operation at the tenant id, so two tenants using the identical
130
+ * namespace tuple (e.g. `["memories"]`) land in physically distinct
131
+ * locations.
132
+ *
133
+ * Why this differs from the Python package: LangGraph.js wraps the compiled
134
+ * graph's store in an `AsyncBatchedStore` whose background queue loses the
135
+ * run's AsyncLocalStorage context, so the tenant CANNOT be resolved ambiently
136
+ * inside `batch()`. Instead, tenant scoping happens at the call site — where
137
+ * the config is available — and `TenantScopedStore.batch()` fails closed on
138
+ * any operation that did not go through a scoped entry point:
139
+ *
140
+ * - inside nodes: `getTenantStore(config)` (reads `tenant_id` from config,
141
+ * delegates to `config.store` so batching still applies)
142
+ * - outside runs: `store.forTenant(tenantId)`
143
+ * - anything else — including raw `config.store.put(...)` in a node —
144
+ * throws `UnscopedAccessError` instead of silently writing unscoped.
145
+ */
146
+
147
+ /**
148
+ * Marker label proving an operation went through a tenant-scoped entry
149
+ * point. Stripped before the operation reaches the inner store.
150
+ */
151
+ declare const TENANT_NS_SENTINEL = "~tenant~";
152
+ declare class TenantScopedStore extends BaseStore {
153
+ readonly inner: BaseStore;
154
+ constructor(inner: BaseStore);
155
+ /** A view of the store pinned to one tenant, for use outside a run. */
156
+ forTenant(tenantId: string): TenantStoreView;
157
+ batch<Op extends Operation[]>(operations: Op): Promise<OperationResults<Op>>;
158
+ private requireScoped;
159
+ private assertSentinel;
160
+ start(): Promise<void>;
161
+ stop(): Promise<void>;
162
+ }
163
+ /**
164
+ * The store surface a tenant view can delegate to: either the
165
+ * `TenantScopedStore` itself (out-of-band) or `config.store` inside a node
166
+ * (an `AsyncBatchedStore`, which implements get/search/put/delete only).
167
+ */
168
+ interface TenantStoreTarget {
169
+ get(namespace: string[], key: string): Promise<Item | null>;
170
+ search(namespacePrefix: string[], options?: {
171
+ filter?: Record<string, any>;
172
+ limit?: number;
173
+ offset?: number;
174
+ query?: string;
175
+ }): Promise<SearchItem[]>;
176
+ put(namespace: string[], key: string, value: Record<string, any>): Promise<void>;
177
+ delete(namespace: string[], key: string): Promise<void>;
178
+ listNamespaces?(options?: {
179
+ prefix?: string[];
180
+ suffix?: string[];
181
+ maxDepth?: number;
182
+ limit?: number;
183
+ offset?: number;
184
+ }): Promise<string[][]>;
185
+ }
186
+ /** All operations rooted at one tenant; namespaces in results come back unprefixed. */
187
+ declare class TenantStoreView {
188
+ private readonly target;
189
+ readonly tenant: string;
190
+ constructor(target: TenantStoreTarget, tenant: string);
191
+ private scopeNs;
192
+ private unscopeItem;
193
+ get(namespace: string[], key: string): Promise<Item | null>;
194
+ search(namespacePrefix: string[], options?: Parameters<TenantStoreTarget["search"]>[1]): Promise<SearchItem[]>;
195
+ put(namespace: string[], key: string, value: Record<string, any>): Promise<void>;
196
+ delete(namespace: string[], key: string): Promise<void>;
197
+ listNamespaces(options?: {
198
+ prefix?: string[];
199
+ suffix?: string[];
200
+ maxDepth?: number;
201
+ limit?: number;
202
+ offset?: number;
203
+ }): Promise<string[][]>;
204
+ }
205
+ /**
206
+ * Tenant-scoped store access inside a node. Reads `tenant_id` from the
207
+ * node's config and delegates to `config.store`, so LangGraph's operation
208
+ * batching still applies.
209
+ *
210
+ * ```ts
211
+ * const node = async (state, config) => {
212
+ * const store = getTenantStore(config);
213
+ * await store.put(["memories"], "k", { note: "..." });
214
+ * };
215
+ * ```
216
+ */
217
+ declare function getTenantStore(config: {
218
+ configurable?: Record<string, unknown>;
219
+ store?: TenantStoreTarget;
220
+ }): TenantStoreView;
221
+
222
+ /** Separator between tenant id and thread id in physical storage keys. */
223
+ declare const SEP = "::";
224
+ declare function validateTenant(tenant: unknown, where: string): string;
225
+
226
+ export { InMemoryUsageLedger, InvalidTenantError, SEP, TENANT_NS_SENTINEL, TenancyError, TenantCheckpointerHandle, TenantRequiredError, TenantScopedCheckpointer, TenantScopedStore, type TenantStoreTarget, TenantStoreView, type TenantUsage, UnscopedAccessError, type UsageLedger, type UsageRecord, extractUsage, getTenantStore, validateTenant };
@@ -0,0 +1,226 @@
1
+ import { RunnableConfig } from '@langchain/core/runnables';
2
+ import { BaseCheckpointSaver, CheckpointTuple, CheckpointListOptions, Checkpoint, CheckpointMetadata, ChannelVersions, PendingWrite, BaseStore, Item, SearchItem, Operation, OperationResults } from '@langchain/langgraph-checkpoint';
3
+
4
+ /**
5
+ * Per-tenant token usage, extracted at the checkpoint boundary.
6
+ *
7
+ * LangGraph already persists `usage_metadata` on every AI message inside
8
+ * `checkpoint.channel_values` — it is just never indexed or aggregated.
9
+ * Since the tenant-scoped checkpointer sees every checkpoint anyway, it can
10
+ * pull usage out and attribute it to the tenant for free.
11
+ *
12
+ * Messages are deduplicated by message id, so re-checkpointing the same
13
+ * conversation does not double-count.
14
+ */
15
+ interface UsageRecord {
16
+ messageId: string;
17
+ model?: string;
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ totalTokens: number;
21
+ }
22
+ /**
23
+ * Anything that can receive per-tenant usage records. Swap the in-memory
24
+ * default for one backed by your own database, StatsD, OpenMeter, etc.
25
+ */
26
+ interface UsageLedger {
27
+ record(tenantId: string, record: UsageRecord): void;
28
+ }
29
+ interface TenantUsage {
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ totalTokens: number;
33
+ messages: number;
34
+ byModel: Record<string, number>;
35
+ }
36
+ /** Reference ledger: per-tenant totals, deduped by message id. */
37
+ declare class InMemoryUsageLedger implements UsageLedger {
38
+ private seen;
39
+ private byTenant;
40
+ record(tenantId: string, record: UsageRecord): void;
41
+ totals(tenantId: string): TenantUsage;
42
+ }
43
+ /** Pull usage records out of message objects in a checkpoint's channels. */
44
+ declare function extractUsage(checkpoint: {
45
+ channel_values?: Record<string, unknown>;
46
+ }): UsageRecord[];
47
+
48
+ /**
49
+ * Tenant-scoped wrapper around any `BaseCheckpointSaver`.
50
+ *
51
+ * - Reads `tenant_id` from `config.configurable` on every call. Missing
52
+ * tenant -> `TenantRequiredError`. There is no unscoped fallback.
53
+ * - Physically prefixes `thread_id` with the tenant (`acme::thread-1`) before
54
+ * it reaches the inner saver, and strips the prefix from everything
55
+ * returned. A wrong-thread_id bug in app code therefore cannot cross a
56
+ * tenant boundary: the key the database sees is always tenant-qualified.
57
+ * - Blocks the dangerous raw-API escape hatch: `deleteThread()` takes a bare
58
+ * thread id with no config, so it is refused and redirected to an explicit
59
+ * `forTenant()` handle.
60
+ * - Optionally records per-tenant token usage from checkpointed messages
61
+ * into a `UsageLedger` — same integration point, free metering.
62
+ */
63
+
64
+ declare class TenantScopedCheckpointer extends BaseCheckpointSaver {
65
+ readonly inner: BaseCheckpointSaver;
66
+ readonly usageLedger?: UsageLedger;
67
+ constructor(inner: BaseCheckpointSaver, options?: {
68
+ usageLedger?: UsageLedger;
69
+ });
70
+ private scope;
71
+ private unscopeConfig;
72
+ private unscopeTuple;
73
+ getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined>;
74
+ list(config: RunnableConfig, options?: CheckpointListOptions): AsyncGenerator<CheckpointTuple>;
75
+ put(config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, newVersions: ChannelVersions): Promise<RunnableConfig>;
76
+ putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise<void>;
77
+ deleteThread(_threadId: string): Promise<void>;
78
+ getNextVersion(current: number | undefined): number;
79
+ /** Admin/maintenance handle pinned to one tenant. */
80
+ forTenant(tenantId: string): TenantCheckpointerHandle;
81
+ }
82
+ /**
83
+ * Maintenance operations pre-bound to a single tenant. Exists because
84
+ * `deleteThread` takes a bare thread id with no config, so there is no
85
+ * per-call tenant to read.
86
+ */
87
+ declare class TenantCheckpointerHandle {
88
+ private readonly inner;
89
+ private readonly tenant;
90
+ constructor(inner: BaseCheckpointSaver, tenant: string);
91
+ deleteThread(threadId: string): Promise<void>;
92
+ }
93
+
94
+ /**
95
+ * Errors raised by langgraph-tenancy. Every error here exists to turn a
96
+ * silent cross-tenant leak into a loud failure.
97
+ */
98
+ declare class TenancyError extends Error {
99
+ name: string;
100
+ }
101
+ /**
102
+ * An operation ran without a tenant in scope. There is no unscoped fallback:
103
+ * no tenant, no data.
104
+ */
105
+ declare class TenantRequiredError extends TenancyError {
106
+ name: string;
107
+ constructor(where: string);
108
+ }
109
+ /**
110
+ * An operation would touch data across tenant boundaries (e.g. a store call
111
+ * that bypassed tenant scoping, or deleteThread without a tenant handle).
112
+ */
113
+ declare class UnscopedAccessError extends TenancyError {
114
+ name: string;
115
+ }
116
+ /**
117
+ * A tenant id that could be used to escape its scope. Tenant ids become part
118
+ * of storage keys, so they must not contain the separator or be empty.
119
+ */
120
+ declare class InvalidTenantError extends TenancyError {
121
+ name: string;
122
+ }
123
+
124
+ /**
125
+ * Tenant-scoped wrapper around any `BaseStore`.
126
+ *
127
+ * Raw `BaseStore` namespaces are pure convention — any caller can `get()`,
128
+ * `search()`, or `listNamespaces()` across all of them. This wrapper roots
129
+ * every operation at the tenant id, so two tenants using the identical
130
+ * namespace tuple (e.g. `["memories"]`) land in physically distinct
131
+ * locations.
132
+ *
133
+ * Why this differs from the Python package: LangGraph.js wraps the compiled
134
+ * graph's store in an `AsyncBatchedStore` whose background queue loses the
135
+ * run's AsyncLocalStorage context, so the tenant CANNOT be resolved ambiently
136
+ * inside `batch()`. Instead, tenant scoping happens at the call site — where
137
+ * the config is available — and `TenantScopedStore.batch()` fails closed on
138
+ * any operation that did not go through a scoped entry point:
139
+ *
140
+ * - inside nodes: `getTenantStore(config)` (reads `tenant_id` from config,
141
+ * delegates to `config.store` so batching still applies)
142
+ * - outside runs: `store.forTenant(tenantId)`
143
+ * - anything else — including raw `config.store.put(...)` in a node —
144
+ * throws `UnscopedAccessError` instead of silently writing unscoped.
145
+ */
146
+
147
+ /**
148
+ * Marker label proving an operation went through a tenant-scoped entry
149
+ * point. Stripped before the operation reaches the inner store.
150
+ */
151
+ declare const TENANT_NS_SENTINEL = "~tenant~";
152
+ declare class TenantScopedStore extends BaseStore {
153
+ readonly inner: BaseStore;
154
+ constructor(inner: BaseStore);
155
+ /** A view of the store pinned to one tenant, for use outside a run. */
156
+ forTenant(tenantId: string): TenantStoreView;
157
+ batch<Op extends Operation[]>(operations: Op): Promise<OperationResults<Op>>;
158
+ private requireScoped;
159
+ private assertSentinel;
160
+ start(): Promise<void>;
161
+ stop(): Promise<void>;
162
+ }
163
+ /**
164
+ * The store surface a tenant view can delegate to: either the
165
+ * `TenantScopedStore` itself (out-of-band) or `config.store` inside a node
166
+ * (an `AsyncBatchedStore`, which implements get/search/put/delete only).
167
+ */
168
+ interface TenantStoreTarget {
169
+ get(namespace: string[], key: string): Promise<Item | null>;
170
+ search(namespacePrefix: string[], options?: {
171
+ filter?: Record<string, any>;
172
+ limit?: number;
173
+ offset?: number;
174
+ query?: string;
175
+ }): Promise<SearchItem[]>;
176
+ put(namespace: string[], key: string, value: Record<string, any>): Promise<void>;
177
+ delete(namespace: string[], key: string): Promise<void>;
178
+ listNamespaces?(options?: {
179
+ prefix?: string[];
180
+ suffix?: string[];
181
+ maxDepth?: number;
182
+ limit?: number;
183
+ offset?: number;
184
+ }): Promise<string[][]>;
185
+ }
186
+ /** All operations rooted at one tenant; namespaces in results come back unprefixed. */
187
+ declare class TenantStoreView {
188
+ private readonly target;
189
+ readonly tenant: string;
190
+ constructor(target: TenantStoreTarget, tenant: string);
191
+ private scopeNs;
192
+ private unscopeItem;
193
+ get(namespace: string[], key: string): Promise<Item | null>;
194
+ search(namespacePrefix: string[], options?: Parameters<TenantStoreTarget["search"]>[1]): Promise<SearchItem[]>;
195
+ put(namespace: string[], key: string, value: Record<string, any>): Promise<void>;
196
+ delete(namespace: string[], key: string): Promise<void>;
197
+ listNamespaces(options?: {
198
+ prefix?: string[];
199
+ suffix?: string[];
200
+ maxDepth?: number;
201
+ limit?: number;
202
+ offset?: number;
203
+ }): Promise<string[][]>;
204
+ }
205
+ /**
206
+ * Tenant-scoped store access inside a node. Reads `tenant_id` from the
207
+ * node's config and delegates to `config.store`, so LangGraph's operation
208
+ * batching still applies.
209
+ *
210
+ * ```ts
211
+ * const node = async (state, config) => {
212
+ * const store = getTenantStore(config);
213
+ * await store.put(["memories"], "k", { note: "..." });
214
+ * };
215
+ * ```
216
+ */
217
+ declare function getTenantStore(config: {
218
+ configurable?: Record<string, unknown>;
219
+ store?: TenantStoreTarget;
220
+ }): TenantStoreView;
221
+
222
+ /** Separator between tenant id and thread id in physical storage keys. */
223
+ declare const SEP = "::";
224
+ declare function validateTenant(tenant: unknown, where: string): string;
225
+
226
+ export { InMemoryUsageLedger, InvalidTenantError, SEP, TENANT_NS_SENTINEL, TenancyError, TenantCheckpointerHandle, TenantRequiredError, TenantScopedCheckpointer, TenantScopedStore, type TenantStoreTarget, TenantStoreView, type TenantUsage, UnscopedAccessError, type UsageLedger, type UsageRecord, extractUsage, getTenantStore, validateTenant };
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ // src/checkpointer.ts
2
+ import {
3
+ BaseCheckpointSaver
4
+ } from "@langchain/langgraph-checkpoint";
5
+
6
+ // src/errors.ts
7
+ var TenancyError = class extends Error {
8
+ name = "TenancyError";
9
+ };
10
+ var TenantRequiredError = class extends TenancyError {
11
+ name = "TenantRequiredError";
12
+ constructor(where) {
13
+ super(
14
+ `${where} requires a tenant. Pass it in the run config: { configurable: { thread_id, tenant_id } }, use getTenantStore(config) inside nodes, or .forTenant(tenantId) for out-of-band access.`
15
+ );
16
+ }
17
+ };
18
+ var UnscopedAccessError = class extends TenancyError {
19
+ name = "UnscopedAccessError";
20
+ };
21
+ var InvalidTenantError = class extends TenancyError {
22
+ name = "InvalidTenantError";
23
+ };
24
+
25
+ // src/tenant.ts
26
+ var SEP = "::";
27
+ function validateTenant(tenant, where) {
28
+ if (typeof tenant !== "string" || tenant.length === 0) {
29
+ throw new TenantRequiredError(where);
30
+ }
31
+ if (tenant.includes(SEP)) {
32
+ throw new InvalidTenantError(
33
+ `tenant_id may not contain '${SEP}': ${JSON.stringify(tenant)}`
34
+ );
35
+ }
36
+ return tenant;
37
+ }
38
+
39
+ // src/usage.ts
40
+ var emptyUsage = () => ({
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ totalTokens: 0,
44
+ messages: 0,
45
+ byModel: {}
46
+ });
47
+ var InMemoryUsageLedger = class {
48
+ seen = /* @__PURE__ */ new Set();
49
+ byTenant = /* @__PURE__ */ new Map();
50
+ record(tenantId, record) {
51
+ const key = `${tenantId}\0${record.messageId}`;
52
+ if (this.seen.has(key)) return;
53
+ this.seen.add(key);
54
+ let usage = this.byTenant.get(tenantId);
55
+ if (!usage) {
56
+ usage = emptyUsage();
57
+ this.byTenant.set(tenantId, usage);
58
+ }
59
+ usage.inputTokens += record.inputTokens;
60
+ usage.outputTokens += record.outputTokens;
61
+ usage.totalTokens += record.totalTokens;
62
+ usage.messages += 1;
63
+ if (record.model) {
64
+ usage.byModel[record.model] = (usage.byModel[record.model] ?? 0) + record.totalTokens;
65
+ }
66
+ }
67
+ totals(tenantId) {
68
+ return this.byTenant.get(tenantId) ?? emptyUsage();
69
+ }
70
+ };
71
+ function extractUsage(checkpoint) {
72
+ const records = [];
73
+ for (const value of Object.values(checkpoint.channel_values ?? {})) {
74
+ const items = Array.isArray(value) ? value : [value];
75
+ for (const item of items) {
76
+ const message = item;
77
+ const usage = message?.usage_metadata;
78
+ const id = message?.id;
79
+ if (!usage || typeof id !== "string") continue;
80
+ records.push({
81
+ messageId: id,
82
+ model: message?.response_metadata?.model_name,
83
+ inputTokens: usage.input_tokens ?? 0,
84
+ outputTokens: usage.output_tokens ?? 0,
85
+ totalTokens: usage.total_tokens ?? 0
86
+ });
87
+ }
88
+ }
89
+ return records;
90
+ }
91
+
92
+ // src/checkpointer.ts
93
+ var TenantScopedCheckpointer = class extends BaseCheckpointSaver {
94
+ inner;
95
+ usageLedger;
96
+ constructor(inner, options) {
97
+ super(inner.serde);
98
+ this.inner = inner;
99
+ this.usageLedger = options?.usageLedger;
100
+ }
101
+ scope(config, where) {
102
+ const conf = config?.configurable ?? {};
103
+ const tenant = validateTenant(conf.tenant_id, where);
104
+ return [
105
+ tenant,
106
+ {
107
+ ...config,
108
+ configurable: { ...conf, thread_id: `${tenant}${SEP}${conf.thread_id}` }
109
+ }
110
+ ];
111
+ }
112
+ unscopeConfig(tenant, config) {
113
+ if (!config) return config;
114
+ const conf = { ...config.configurable ?? {} };
115
+ const threadId = conf.thread_id;
116
+ const prefix = `${tenant}${SEP}`;
117
+ if (typeof threadId === "string" && threadId.startsWith(prefix)) {
118
+ conf.thread_id = threadId.slice(prefix.length);
119
+ conf.tenant_id = tenant;
120
+ }
121
+ return { ...config, configurable: conf };
122
+ }
123
+ unscopeTuple(tenant, tuple) {
124
+ if (!tuple) return tuple;
125
+ return {
126
+ ...tuple,
127
+ config: this.unscopeConfig(tenant, tuple.config),
128
+ parentConfig: this.unscopeConfig(tenant, tuple.parentConfig)
129
+ };
130
+ }
131
+ async getTuple(config) {
132
+ const [tenant, scoped] = this.scope(config, "getTuple()");
133
+ return this.unscopeTuple(tenant, await this.inner.getTuple(scoped));
134
+ }
135
+ async *list(config, options) {
136
+ const [tenant, scoped] = this.scope(config ?? {}, "list()");
137
+ const scopedOptions = options?.before ? { ...options, before: this.scope(options.before, "list({before})")[1] } : options;
138
+ for await (const tuple of this.inner.list(scoped, scopedOptions)) {
139
+ yield this.unscopeTuple(tenant, tuple);
140
+ }
141
+ }
142
+ async put(config, checkpoint, metadata, newVersions) {
143
+ const [tenant, scoped] = this.scope(config, "put()");
144
+ if (this.usageLedger) {
145
+ for (const record of extractUsage(checkpoint)) {
146
+ this.usageLedger.record(tenant, record);
147
+ }
148
+ }
149
+ return this.unscopeConfig(
150
+ tenant,
151
+ await this.inner.put(scoped, checkpoint, metadata, newVersions)
152
+ );
153
+ }
154
+ async putWrites(config, writes, taskId) {
155
+ const [, scoped] = this.scope(config, "putWrites()");
156
+ return this.inner.putWrites(scoped, writes, taskId);
157
+ }
158
+ async deleteThread(_threadId) {
159
+ throw new UnscopedAccessError(
160
+ "deleteThread() has no tenant context; use forTenant(tenantId).deleteThread(threadId)."
161
+ );
162
+ }
163
+ getNextVersion(current) {
164
+ return this.inner.getNextVersion(current);
165
+ }
166
+ /** Admin/maintenance handle pinned to one tenant. */
167
+ forTenant(tenantId) {
168
+ return new TenantCheckpointerHandle(
169
+ this.inner,
170
+ validateTenant(tenantId, "forTenant()")
171
+ );
172
+ }
173
+ };
174
+ var TenantCheckpointerHandle = class {
175
+ constructor(inner, tenant) {
176
+ this.inner = inner;
177
+ this.tenant = tenant;
178
+ }
179
+ inner;
180
+ tenant;
181
+ async deleteThread(threadId) {
182
+ return this.inner.deleteThread(`${this.tenant}${SEP}${threadId}`);
183
+ }
184
+ };
185
+
186
+ // src/store.ts
187
+ import {
188
+ BaseStore
189
+ } from "@langchain/langgraph-checkpoint";
190
+ var TENANT_NS_SENTINEL = "~tenant~";
191
+ var UNSCOPED_MESSAGE = "Store accessed without tenant scoping. Use getTenantStore(config) inside nodes, or store.forTenant(tenantId) outside runs. Raw store access is refused so it cannot silently read or write across tenants.";
192
+ var TenantScopedStore = class extends BaseStore {
193
+ inner;
194
+ constructor(inner) {
195
+ super();
196
+ this.inner = inner;
197
+ }
198
+ /** A view of the store pinned to one tenant, for use outside a run. */
199
+ forTenant(tenantId) {
200
+ return new TenantStoreView(this, validateTenant(tenantId, "forTenant()"));
201
+ }
202
+ async batch(operations) {
203
+ const scoped = operations.map((op) => this.requireScoped(op));
204
+ return this.inner.batch(scoped);
205
+ }
206
+ requireScoped(op) {
207
+ if ("namespacePrefix" in op) {
208
+ this.assertSentinel(op.namespacePrefix);
209
+ return { ...op, namespacePrefix: op.namespacePrefix.slice(1) };
210
+ }
211
+ if ("namespace" in op) {
212
+ this.assertSentinel(op.namespace);
213
+ return { ...op, namespace: op.namespace.slice(1) };
214
+ }
215
+ const conditions = op.matchConditions ?? [];
216
+ const prefixIndex = conditions.findIndex((c) => c.matchType === "prefix");
217
+ if (prefixIndex < 0) throw new UnscopedAccessError(UNSCOPED_MESSAGE);
218
+ this.assertSentinel(conditions[prefixIndex].path);
219
+ const next = conditions.map(
220
+ (c, i) => i === prefixIndex ? { ...c, path: c.path.slice(1) } : c
221
+ );
222
+ return { ...op, matchConditions: next };
223
+ }
224
+ assertSentinel(namespace) {
225
+ if (namespace[0] !== TENANT_NS_SENTINEL || typeof namespace[1] !== "string" || namespace[1].length === 0) {
226
+ throw new UnscopedAccessError(UNSCOPED_MESSAGE);
227
+ }
228
+ }
229
+ async start() {
230
+ await this.inner.start();
231
+ }
232
+ async stop() {
233
+ await this.inner.stop();
234
+ }
235
+ };
236
+ var TenantStoreView = class {
237
+ constructor(target, tenant) {
238
+ this.target = target;
239
+ this.tenant = tenant;
240
+ }
241
+ target;
242
+ tenant;
243
+ scopeNs(namespace) {
244
+ return [TENANT_NS_SENTINEL, this.tenant, ...namespace];
245
+ }
246
+ unscopeItem(item) {
247
+ if (item) item.namespace = item.namespace.slice(1);
248
+ return item;
249
+ }
250
+ async get(namespace, key) {
251
+ return this.unscopeItem(await this.target.get(this.scopeNs(namespace), key));
252
+ }
253
+ async search(namespacePrefix, options) {
254
+ const items = await this.target.search(
255
+ this.scopeNs(namespacePrefix),
256
+ options
257
+ );
258
+ for (const item of items) this.unscopeItem(item);
259
+ return items;
260
+ }
261
+ async put(namespace, key, value) {
262
+ return this.target.put(this.scopeNs(namespace), key, value);
263
+ }
264
+ async delete(namespace, key) {
265
+ return this.target.delete(this.scopeNs(namespace), key);
266
+ }
267
+ async listNamespaces(options = {}) {
268
+ if (!this.target.listNamespaces) {
269
+ throw new TenancyError(
270
+ "listNamespaces is not available through config.store (LangGraph batches in-graph store access); call it on store.forTenant(tenantId) instead."
271
+ );
272
+ }
273
+ const results = await this.target.listNamespaces({
274
+ ...options,
275
+ prefix: this.scopeNs(options.prefix ?? []),
276
+ maxDepth: options.maxDepth == null ? void 0 : options.maxDepth + 1
277
+ });
278
+ return results.filter((ns) => ns[0] === this.tenant).map((ns) => ns.slice(1));
279
+ }
280
+ };
281
+ function getTenantStore(config) {
282
+ const tenant = validateTenant(
283
+ config?.configurable?.tenant_id,
284
+ "getTenantStore()"
285
+ );
286
+ if (!config.store) {
287
+ throw new TenancyError(
288
+ "getTenantStore(config): config.store is missing \u2014 was the graph compiled with a store?"
289
+ );
290
+ }
291
+ return new TenantStoreView(config.store, tenant);
292
+ }
293
+ export {
294
+ InMemoryUsageLedger,
295
+ InvalidTenantError,
296
+ SEP,
297
+ TENANT_NS_SENTINEL,
298
+ TenancyError,
299
+ TenantCheckpointerHandle,
300
+ TenantRequiredError,
301
+ TenantScopedCheckpointer,
302
+ TenantScopedStore,
303
+ TenantStoreView,
304
+ UnscopedAccessError,
305
+ extractUsage,
306
+ getTenantStore,
307
+ validateTenant
308
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "langgraph-tenancy",
3
+ "version": "0.1.0",
4
+ "description": "Tenant isolation and per-tenant usage metering for LangGraph.js checkpointers and stores",
5
+ "license": "MIT",
6
+ "author": "Abhishek Chauhan <ac12644@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ac12644/langgraph-tenancy-js.git"
10
+ },
11
+ "homepage": "https://github.com/ac12644/langgraph-tenancy-js#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ac12644/langgraph-tenancy-js/issues"
14
+ },
15
+ "keywords": [
16
+ "langgraph",
17
+ "langchain",
18
+ "multi-tenant",
19
+ "tenant-isolation",
20
+ "checkpointer",
21
+ "agents",
22
+ "llm"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "require": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
43
+ "test": "vitest run",
44
+ "lint": "tsc --noEmit"
45
+ },
46
+ "peerDependencies": {
47
+ "@langchain/langgraph-checkpoint": ">=0.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@langchain/core": "^1.1.0",
51
+ "@langchain/langgraph": "^1.0.0",
52
+ "@langchain/langgraph-checkpoint": "^1.1.0",
53
+ "tsup": "^8.0.0",
54
+ "typescript": "^5.6.0",
55
+ "vitest": "^3.0.0"
56
+ }
57
+ }