This repository has been archived on 2026-03-27. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drive/server/keto.ts
Sienna Meridian Satterwhite 58237d9e44 Initial commit — Drive, an S3 file browser with WOPI editing
Lightweight replacement for the upstream La Suite Numérique drive
(Django/Celery/Next.js) built as a single Deno binary.

Server (Deno + Hono):
- S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs
- WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks)
- Ory Kratos session auth + CSRF protection
- Ory Keto permission model (OPL namespaces, not yet wired to routes)
- PostgreSQL metadata with recursive folder sizes
- S3 backfill API for registering files uploaded outside the UI
- OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED)

Frontend (React 19 + Cunningham v4 + react-aria):
- File browser with GridList, keyboard nav, multi-select
- Collabora editor iframe (full-screen, form POST, postMessage)
- Profile menu, waffle menu, drag-drop upload, asset type badges
- La Suite integration service theming (runtime CSS)

Testing (549 tests):
- 235 server unit tests (Deno) — 90%+ coverage
- 278 UI unit tests (Vitest) — 90%+ coverage
- 11 E2E tests (Playwright)
- 12 integration service tests (Playwright)
- 13 WOPI integration tests (Playwright + Docker Compose + Collabora)

MIT licensed.
2026-03-25 18:28:37 +00:00

228 lines
7.5 KiB
TypeScript

/**
* Lightweight HTTP client for Ory Keto — no SDK, just fetch.
*/
import { withSpan } from "./telemetry.ts";
const KETO_READ_URL = Deno.env.get("KETO_READ_URL") ??
"http://keto-read.ory.svc.cluster.local:4466";
const KETO_WRITE_URL = Deno.env.get("KETO_WRITE_URL") ??
"http://keto-write.ory.svc.cluster.local:4467";
// ── Types ────────────────────────────────────────────────────────────────────
export interface RelationTuple {
namespace: string;
object: string;
relation: string;
subject_id?: string;
subject_set?: {
namespace: string;
object: string;
relation: string;
};
}
export interface RelationPatch {
action: "insert" | "delete";
relation_tuple: RelationTuple;
}
// ── Check ────────────────────────────────────────────────────────────────────
/**
* Check whether `subjectId` has `relation` on `namespace:object`.
* Returns false on network errors instead of throwing.
*/
export async function checkPermission(
namespace: string,
object: string,
relation: string,
subjectId: string,
): Promise<boolean> {
return withSpan("keto.checkPermission", { "keto.namespace": namespace, "keto.relation": relation }, async () => {
try {
const res = await fetch(
`${KETO_READ_URL}/relation-tuples/check/openapi`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace, object, relation, subject_id: subjectId }),
},
);
if (!res.ok) return false;
const body = await res.json();
return body.allowed === true;
} catch {
return false;
}
});
}
// ── Write ────────────────────────────────────────────────────────────────────
/**
* Create a relationship tuple with a direct subject.
*/
export async function createRelationship(
namespace: string,
object: string,
relation: string,
subjectId: string,
): Promise<void> {
return withSpan("keto.createRelationship", { "keto.namespace": namespace, "keto.relation": relation }, async () => {
const res = await fetch(`${KETO_WRITE_URL}/admin/relation-tuples`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace, object, relation, subject_id: subjectId }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Keto createRelationship failed (${res.status}): ${text}`,
);
}
});
}
/**
* Create a relationship tuple with a subject set (e.g. parent folder).
*/
export async function createRelationshipWithSubjectSet(
namespace: string,
object: string,
relation: string,
subjectSetNamespace: string,
subjectSetObject: string,
subjectSetRelation: string,
): Promise<void> {
return withSpan("keto.createRelationshipWithSubjectSet", { "keto.namespace": namespace, "keto.relation": relation }, async () => {
const res = await fetch(`${KETO_WRITE_URL}/admin/relation-tuples`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
namespace,
object,
relation,
subject_set: {
namespace: subjectSetNamespace,
object: subjectSetObject,
relation: subjectSetRelation,
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Keto createRelationshipWithSubjectSet failed (${res.status}): ${text}`,
);
}
});
}
// ── Delete ───────────────────────────────────────────────────────────────────
/**
* Delete a relationship tuple.
*/
export async function deleteRelationship(
namespace: string,
object: string,
relation: string,
subjectId: string,
): Promise<void> {
return withSpan("keto.deleteRelationship", { "keto.namespace": namespace, "keto.relation": relation }, async () => {
const params = new URLSearchParams({ namespace, object, relation, subject_id: subjectId });
const res = await fetch(
`${KETO_WRITE_URL}/admin/relation-tuples?${params}`,
{ method: "DELETE" },
);
if (!res.ok) {
const text = await res.text();
throw new Error(
`Keto deleteRelationship failed (${res.status}): ${text}`,
);
}
});
}
// ── Batch ────────────────────────────────────────────────────────────────────
/**
* Apply a batch of insert/delete patches atomically.
*/
export async function batchWriteRelationships(
patches: RelationPatch[],
): Promise<void> {
return withSpan("keto.batchWriteRelationships", { "keto.patch_count": patches.length }, async () => {
const res = await fetch(`${KETO_WRITE_URL}/admin/relation-tuples`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patches),
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Keto batchWriteRelationships failed (${res.status}): ${text}`,
);
}
});
}
// ── List ─────────────────────────────────────────────────────────────────────
/**
* List relationship tuples matching the given filters.
*/
export async function listRelationships(
namespace: string,
object?: string,
relation?: string,
subjectId?: string,
): Promise<RelationTuple[]> {
return withSpan("keto.listRelationships", { "keto.namespace": namespace }, async () => {
const params = new URLSearchParams({ namespace });
if (object) params.set("object", object);
if (relation) params.set("relation", relation);
if (subjectId) params.set("subject_id", subjectId);
const res = await fetch(
`${KETO_READ_URL}/relation-tuples?${params}`,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Keto listRelationships failed (${res.status}): ${text}`);
}
const body = await res.json();
return body.relation_tuples ?? [];
});
}
// ── Expand ───────────────────────────────────────────────────────────────────
/**
* Expand a permission tree for debugging / UI display.
*/
export async function expandPermission(
namespace: string,
object: string,
relation: string,
maxDepth?: number,
): Promise<unknown> {
return withSpan("keto.expandPermission", { "keto.namespace": namespace, "keto.relation": relation }, async () => {
const res = await fetch(
`${KETO_READ_URL}/relation-tuples/expand`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace, object, relation, max_depth: maxDepth ?? 3 }),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Keto expandPermission failed (${res.status}): ${text}`);
}
return await res.json();
});
}