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.
This commit is contained in:
227
server/keto.ts
Normal file
227
server/keto.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user