Mémoire avec backend hôte
Une mémoire logique n’impose pas à elle seule un backend. Ici, required_resources rend ce backend obligatoire quand le document de ressources est fourni. Gardez requireResourceBindings: true (CLI : --require-resource-bindings) pour refuser avant dispatch même si ce document est omis. Un secret court peut coïncider avec du texte ordinaire ou une clé d’objet : utilisez un credential dédié à forte entropie ; le runtime refuse volontairement toute sous-chaîne correspondante.
Écrire une note client dans un vrai service HTTP local, puis la relire. Touba gouverne mémoire logique, scopes, grants, secrets et effets ; l'hôte implémente le stockage. Ce service de démonstration garde les données en mémoire de processus : son arrêt perd les notes. Pour la persistance, poursuivre avec PostgreSQL.
Prérequis : distribution Touba, Node.js 24 et npm. Docker est requis uniquement pour le second mode d'exécution. Dans un terminal macOS/Linux :
mkdir -p http-memory-demo
cd http-memory-demo
mkdir -p handlers backend docker vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz
1. Déclarer le workflow mémoire
module examples.memory_backend_host
memory CustomerMemory
type MemoryRequest
customer_id is Text
note is Text
type MemoryResult
customer_id is Text
stored_note is Text
backend is Text
tool WriteMemoryNote
description "Store one customer note in the authorized HTTP memory backend."
uses memory CustomerMemory write
takes request as MemoryRequest
gives MemoryResult
requires network, secret_read, memory_write
secrets MEMORY_BACKEND_TOKEN
tool ReadMemoryNote
description "Read the stored customer note from the authorized HTTP backend."
uses memory CustomerMemory read
takes request as MemoryRequest
gives MemoryResult
requires network, secret_read, memory_read
secrets MEMORY_BACKEND_TOKEN
skill StoreAndReadCustomerMemory
description "Store the requested note and verify it by reading the backend."
takes request as MemoryRequest
gives MemoryResult
uses WriteMemoryNote
uses ReadMemoryNote
memory CustomerMemory read
memory CustomerMemory write
do
WriteMemoryNote
with request as request
into stored
ReadMemoryNote
with request as request
into loaded
give loaded
strategy RunCustomerMemory
takes request as MemoryRequest
gives MemoryResult
uses StoreAndReadCustomerMemory
do
StoreAndReadCustomerMemory
with request as request
into result
show
with message as "Memory backend returned: " ++ result.stored_note
give result
2. Fournir le backend HTTP complet
import { createServer } from "node:http";
import { pathToFileURL } from "node:url";
export async function startMemoryBackend({ token, host = "127.0.0.1", port = 0 }) {
if (!token) throw new Error("A backend token is required");
const notes = new Map();
const server = createServer(async (request, response) => {
const send = (status, body) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
};
try {
if (request.url === "/health") return send(200, { status: "ok" });
if (request.headers.authorization !== "Bearer " + token) return send(401, { error: "Unauthorized" });
const match = /^\/notes\/([^/]+)$/.exec(new URL(request.url, "http://local").pathname);
if (!match) return send(404, { error: "NotFound" });
const id = decodeURIComponent(match[1]);
if (request.method === "POST") {
let raw = "";
for await (const chunk of request) {
raw += chunk;
if (Buffer.byteLength(raw) > 8192) return send(413, { error: "PayloadTooLarge" });
}
const { note } = JSON.parse(raw);
if (typeof note !== "string" || !note.trim()) return send(400, { error: "InvalidNote" });
notes.set(id, { customer_id: id, stored_note: note, write_count: (notes.get(id)?.write_count ?? 0) + 1 });
} else if (request.method !== "GET") return send(405, { error: "MethodNotAllowed" });
return notes.has(id) ? send(200, notes.get(id)) : send(404, { error: "MissingNote" });
} catch { send(400, { error: "InvalidRequest" }); }
});
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, resolve); });
return { endpoint: "http://127.0.0.1:" + server.address().port,
close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await startMemoryBackend({ token: process.env.MEMORY_BACKEND_TOKEN,
host: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 8787) });
}
Ce service local borné est un adaptateur pédagogique, pas une base de production. Il authentifie les requêtes, valide les notes et compte les écritures réelles.
3. Implémenter le handler de lecture/écriture
Le resource binding validé fournit l'URL ; le secret est injecté uniquement dans
le handler. Une écriture retourne un reçu appliqué après le succès HTTP.
retry_mode: none ne promet ni idempotence ni annulation de l'effet.
import { defineTool, createToolResult, ToubaHandlerError } from "@touba/sdk";
const access = process.argv[2];
if (!["read", "write"].includes(access)) throw new Error("Expected read or write adapter mode");
const name = access === "write" ? "WriteMemoryNote" : "ReadMemoryNote";
await defineTool({
toolRef: "tool:examples.memory_backend_host/" + name,
requiredGrants: ["network", "secret_read", "memory_" + access],
requiredSecretHandles: ["MEMORY_BACKEND_TOKEN"],
inputSignature: [{ name: "request", type_ref: "type:examples.memory_backend_host/MemoryRequest" }],
outputType: "type:examples.memory_backend_host/MemoryResult",
run: async ({ request, context }) => {
let requestStarted = false;
try {
if (access === "write" && !context.effect) throw new ToubaHandlerError("EffectContextRequired", "A write requires an effect context.");
const binding = context.resourceBindings.find(item => item.resource_ref === "memory:examples.memory_backend_host/CustomerMemory");
if (binding?.kind !== "memory_backend" || binding.provider !== "host.http.memory_backend") {
throw new ToubaHandlerError("MemoryBindingInvalid", "The expected memory backend is unavailable.");
}
const url = new URL(binding.config.endpoint_url);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") throw new ToubaHandlerError("MemoryEndpointInvalid", "This demo requires loopback HTTP.");
url.pathname = "/notes/" + encodeURIComponent(request.customer_id);
requestStarted = true;
const response = await fetch(url, { method: access === "write" ? "POST" : "GET",
signal: AbortSignal.timeout(10000),
headers: { authorization: "Bearer " + context.env.MEMORY_BACKEND_TOKEN, "content-type": "application/json" },
...(access === "write" ? { body: JSON.stringify({ note: request.note }) } : {}) });
if (!response.ok) throw new ToubaHandlerError("MemoryHttpError", "The memory backend returned HTTP " + response.status);
const body = await response.json();
if (typeof body.customer_id !== "string" || typeof body.stored_note !== "string") {
throw new ToubaHandlerError("MemoryResultInvalid", "The backend returned an invalid record.");
}
const value = { customer_id: body.customer_id, stored_note: body.stored_note, backend: "host_http_memory" };
return access === "write" ? createToolResult(value, {
effectReceipt: context.createEffectReceipt({ outcome: "applied" }) }) : value;
} catch (error) {
if (access === "write" && context.effect) throw new ToubaHandlerError("MemoryWriteUnconfirmed",
"The write did not return a confirmed result.", { effect_receipt: context.createEffectReceipt({
outcome: requestStarted ? "unknown" : "not_applied" }) });
throw error;
}
},
});
4. Déclarer la configuration hôte
import { writeFileSync } from "node:fs";
export function configure(endpoint) {
const memory = "memory:examples.memory_backend_host/CustomerMemory";
const manifest = { manifest_version: "touba-manifest-v1", bindings: {} };
for (const [name, access] of [["WriteMemoryNote", "write"], ["ReadMemoryNote", "read"]]) {
manifest.bindings["tool:examples.memory_backend_host/" + name] = {
handler: { kind: "process", command: process.execPath, args: ["handlers/memory.mjs", access], cwd: "." },
grants: ["network", "secret_read", "memory_" + access], secrets: ["MEMORY_BACKEND_TOKEN"],
memories: [{ ref: memory, access }], timeout_ms: 15000, max_payload_bytes: 65536,
effect_contract: access === "write" ? { kind: "external_write", retry_mode: "none" } : { kind: "none" },
};
}
const resources = { resource_bindings_schema_version: "touba-resource-bindings-v1", required_resources: [memory], resources: {
[memory]: { kind: "memory_backend", provider: "host.http.memory_backend", config: { endpoint_url: endpoint },
grants: ["memory.read", "memory.write"], secrets: ["MEMORY_BACKEND_TOKEN"],
required_secrets: ["MEMORY_BACKEND_TOKEN"], limits: { max_payload_bytes: 65536 } },
} };
const save = (name, value) => writeFileSync(name, JSON.stringify(value, null, 2) + "\n");
save("touba.manifest.json", manifest);
save("touba.resources.json", resources);
save("touba.secrets.json", { secrets_version: "touba-secrets-v1", secrets: {
MEMORY_BACKEND_TOKEN: { source: "env", name: "MEMORY_BACKEND_TOKEN" } } });
resources.resources[memory].grants = ["memory.read"];
save("denied.resources.json", resources);
resources.resources[memory].config.token = "forbidden-demo-value";
save("invalid.resources.json", resources);
}
Le script écrit des fichiers V1 complets avec le vrai chemin Node et le port du
backend. Les grants ressource memory.read/memory.write complètent les scopes
source et le tableau memories du manifest. Aucun token, DSN ou clé dans config.
5. Exécuter et vérifier
Le script démarre un service isolé, vérifie trois refus avant dispatch, effectue une écriture/lecture, contrôle le compteur du backend et ferme son service.
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { ToubaEngine, ToubaEngineError } from "@touba/sdk";
import { startMemoryBackend } from "./backend/server.mjs";
import { configure } from "./configure.mjs";
const run = promisify(execFile);
const token = "isolated-memory-token-" + randomUUID();
const project = "touba-memory-demo-" + randomUUID().slice(0, 8);
const docker = process.argv.includes("--docker");
const compose = (...args) => run("docker", ["compose", "-p", project, "-f", "compose.json", ...args],
{ env: { ...process.env, MEMORY_BACKEND_TOKEN: token }, maxBuffer: 4 * 1024 * 1024 });
let backend;
try {
let endpoint;
if (docker) {
await compose("up", "--build", "--detach", "--wait", "--wait-timeout", "90");
const { stdout } = await compose("port", "backend", "8787");
endpoint = "http://" + stdout.trim();
} else {
backend = await startMemoryBackend({ token });
endpoint = backend.endpoint;
}
configure(endpoint);
const engine = new ToubaEngine({ projectRoot: ".", env: { MEMORY_BACKEND_TOKEN: token } });
const options = { source: "memory.tb", entryRef: "strategy:examples.memory_backend_host/RunCustomerMemory",
manifest: "touba.manifest.json", secrets: "touba.secrets.json", resourceBindings: "touba.resources.json",
requireResourceBindings: true, effectJournal: "memory.effect-journal.sqlite",
inputs: { request: { customer_id: "ACME-001", note: "Renewal risk escalated to the account team" } } };
for (const [resourceBindings, reason] of [[undefined, "ResourceBindingMissing"],
["denied.resources.json", "ResourceBindingGrantMissing"], ["invalid.resources.json", "ResourceBindingPayloadForbidden"]]) {
await assert.rejects(engine.run({ ...options, resourceBindings }), error => {
assert.ok(error instanceof ToubaEngineError);
assert.equal(error.details.reason, reason);
assert.equal(error.details.trace.events.filter(event => event.kind === "tool_binding_authorized").length, 0);
return true;
});
}
const result = await engine.run(options);
assert.equal(result.status, "success");
assert.equal(result.result.stored_note, options.inputs.request.note);
const record = await (await fetch(endpoint + "/notes/ACME-001", { headers: { authorization: "Bearer " + token } })).json();
assert.equal(record.write_count, 1);
for (const value of [token, endpoint, options.inputs.request.note]) assert.ok(!JSON.stringify(result.trace).includes(value));
console.log(JSON.stringify(result.result));
console.log("HTTP_MEMORY_WRITE_READ_AND_REFUSALS_OK " + (docker ? "docker" : "local"));
} finally {
if (backend) await backend.close();
if (docker) await compose("down", "--volumes", "--remove-orphans");
}
node exercise.mjs
Résultat attendu : HTTP_MEMORY_WRITE_READ_AND_REFUSALS_OK local. L'option SDK
explicite effectJournal journalise durablement les écritures. Un refus avant
écriture ne crée aucune note. Un échec ultérieur n'annule pas un effet déjà acquis.
6. Exécuter le même backend dans Docker
Créer ces deux fichiers ; le contexte de build est la racine du projet, chaque chemin copié existe. Le port hôte est alloué dynamiquement pour éviter les conflits.
FROM node:24.22.1-alpine
WORKDIR /app
COPY backend/server.mjs ./server.mjs
CMD ["node", "server.mjs"]
{
"services": {
"backend": {
"build": {
"context": ".",
"dockerfile": "docker/Dockerfile"
},
"environment": {
"MEMORY_BACKEND_TOKEN": "${MEMORY_BACKEND_TOKEN:?required}",
"HOST": "0.0.0.0",
"PORT": "8787"
},
"ports": [
"127.0.0.1::8787"
],
"healthcheck": {
"test": [
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
],
"interval": "1s",
"timeout": "5s",
"retries": 30
}
}
}
}
node exercise.mjs --docker
Résultat attendu : HTTP_MEMORY_WRITE_READ_AND_REFUSALS_OK docker. La commande
crée un projet Compose de nom unique et retire uniquement ses conteneurs et
volumes dans finally. Elle n'utilise et n'arrête aucun projet existant.
Le source, le handler SDK, les contrôles et les assertions métier sont identiques dans les deux modes. Cet exemple n'implique ni PostgreSQL, ni chiffrement, ni sandbox OS. Poursuivre avec la mémoire PostgreSQL.