Initial commit: AS4/411 directory and discovery service for Sankofa Marketplace
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
packages/api/rest/package.json
Normal file
27
packages/api/rest/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@as4-411/api-rest",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "REST API for as4-411 resolver and admin",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@as4-411/core": "workspace:*",
|
||||
"@as4-411/resolver": "workspace:*",
|
||||
"@as4-411/storage": "workspace:*",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
17
packages/api/rest/src/app.ts
Normal file
17
packages/api/rest/src/app.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import express from "express";
|
||||
import { createResolverRouter } from "./routes/resolver.js";
|
||||
import { createSystemRouter } from "./routes/system.js";
|
||||
import { createAdminRouter } from "./routes/admin.js";
|
||||
import type { Resolver } from "@as4-411/resolver";
|
||||
import type { AdminStore } from "@as4-411/storage";
|
||||
|
||||
export function createApp(resolver: Resolver, adminStore?: AdminStore | null): express.Application {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.use(createResolverRouter(resolver));
|
||||
app.use(createSystemRouter());
|
||||
app.use(createAdminRouter(adminStore ?? null));
|
||||
|
||||
return app;
|
||||
}
|
||||
4
packages/api/rest/src/index.ts
Normal file
4
packages/api/rest/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { createResolverRouter } from "./routes/resolver.js";
|
||||
export { createSystemRouter } from "./routes/system.js";
|
||||
export { createAdminRouter } from "./routes/admin.js";
|
||||
276
packages/api/rest/src/routes/admin.ts
Normal file
276
packages/api/rest/src/routes/admin.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { AdminStore } from "@as4-411/storage";
|
||||
import type {
|
||||
Tenant,
|
||||
Participant,
|
||||
Identifier,
|
||||
Endpoint,
|
||||
CredentialRef,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
|
||||
function randomId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin CRUD routes. If adminStore is not provided, returns 501.
|
||||
* Paths match OpenAPI: /v1/admin/tenants, participants, identifiers, endpoints, credentials, policies.
|
||||
*/
|
||||
export function createAdminRouter(adminStore: AdminStore | null): Router {
|
||||
const router = Router();
|
||||
|
||||
if (!adminStore) {
|
||||
const notImplemented = (_req: Request, res: Response) => {
|
||||
res.status(501).json({ error: "Admin API not implemented yet" });
|
||||
};
|
||||
router.get("/v1/admin/tenants", notImplemented);
|
||||
router.post("/v1/admin/tenants", notImplemented);
|
||||
router.get("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.put("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.delete("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.get("/v1/admin/participants", notImplemented);
|
||||
router.post("/v1/admin/participants", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.put("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.delete("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/identifiers", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/identifiers", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/endpoints", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/endpoints", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/credentials", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/credentials", notImplemented);
|
||||
router.get("/v1/admin/policies", notImplemented);
|
||||
router.post("/v1/admin/policies", notImplemented);
|
||||
router.get("/v1/admin/policies/:policyId", notImplemented);
|
||||
router.put("/v1/admin/policies/:policyId", notImplemented);
|
||||
router.delete("/v1/admin/policies/:policyId", notImplemented);
|
||||
return router;
|
||||
}
|
||||
|
||||
// Tenants
|
||||
router.get("/v1/admin/tenants", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const list = await adminStore.listTenants();
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/tenants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Tenant>;
|
||||
const id = body.id ?? randomId();
|
||||
const name = body.name ?? "";
|
||||
await adminStore.createTenant({ id, name });
|
||||
res.status(201).json({ id, name });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const t = await adminStore.getTenant(req.params.tenantId);
|
||||
if (!t) return res.status(404).json({ error: "Not found" });
|
||||
res.json(t);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.updateTenant(req.params.tenantId, {
|
||||
name: (req.body as { name?: string }).name ?? "",
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const t = await adminStore.getTenant(req.params.tenantId);
|
||||
res.json(t);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deleteTenant(req.params.tenantId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Participants
|
||||
router.get("/v1/admin/participants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.query.tenantId as string | undefined;
|
||||
const list = await adminStore.listParticipants({ tenantId });
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Participant>;
|
||||
const id = body.id ?? randomId();
|
||||
const tenantId = body.tenantId ?? "";
|
||||
const name = body.name ?? "";
|
||||
await adminStore.createParticipant({ id, tenantId, name });
|
||||
res.status(201).json({ id, tenantId, name });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const p = await adminStore.getParticipant(req.params.participantId);
|
||||
if (!p) return res.status(404).json({ error: "Not found" });
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const body = req.body as { name?: string };
|
||||
const ok = await adminStore.updateParticipant(req.params.participantId, {
|
||||
name: body.name ?? "",
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const p = await adminStore.getParticipant(req.params.participantId);
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deleteParticipant(req.params.participantId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Identifiers (nested under participant)
|
||||
router.get("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getIdentifiersByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Identifier>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const identifier: Identifier = {
|
||||
id,
|
||||
participantId,
|
||||
identifier_type: body.identifier_type ?? "",
|
||||
value: body.value ?? "",
|
||||
scope: body.scope,
|
||||
priority: body.priority ?? 0,
|
||||
verified_at: body.verified_at,
|
||||
};
|
||||
await adminStore.createIdentifier(identifier);
|
||||
res.status(201).json(identifier);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoints
|
||||
router.get("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getEndpointsByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Endpoint>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const endpoint: Endpoint = {
|
||||
id,
|
||||
participantId,
|
||||
protocol: body.protocol ?? "",
|
||||
address: body.address ?? "",
|
||||
profile: body.profile,
|
||||
priority: body.priority ?? 0,
|
||||
status: body.status ?? "active",
|
||||
};
|
||||
await adminStore.createEndpoint(endpoint);
|
||||
res.status(201).json(endpoint);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Credentials
|
||||
router.get("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getCredentialsByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<CredentialRef>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const credential: CredentialRef = {
|
||||
id,
|
||||
participantId,
|
||||
credential_type: body.credential_type ?? "tls",
|
||||
vault_ref: body.vault_ref ?? "",
|
||||
fingerprint: body.fingerprint,
|
||||
valid_from: body.valid_from,
|
||||
valid_to: body.valid_to,
|
||||
};
|
||||
await adminStore.createCredential(credential);
|
||||
res.status(201).json(credential);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Policies
|
||||
router.get("/v1/admin/policies", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.query.tenantId as string | undefined;
|
||||
const list = await adminStore.listPolicies({ tenantId });
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/policies", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Policy>;
|
||||
const id = body.id ?? randomId();
|
||||
const tenantId = body.tenantId ?? "";
|
||||
const policy: Policy = {
|
||||
id,
|
||||
tenantId,
|
||||
rule_json: body.rule_json ?? {},
|
||||
effect: body.effect ?? "allow",
|
||||
priority: body.priority ?? 0,
|
||||
};
|
||||
await adminStore.createPolicy(policy);
|
||||
res.status(201).json(policy);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const p = await adminStore.getPolicy(req.params.policyId);
|
||||
if (!p) return res.status(404).json({ error: "Not found" });
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const body = req.body as Partial<Policy>;
|
||||
const ok = await adminStore.updatePolicy(req.params.policyId, {
|
||||
rule_json: body.rule_json ?? {},
|
||||
effect: body.effect ?? "allow",
|
||||
priority: body.priority ?? 0,
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const p = await adminStore.getPolicy(req.params.policyId);
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deletePolicy(req.params.policyId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
46
packages/api/rest/src/routes/resolver.ts
Normal file
46
packages/api/rest/src/routes/resolver.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Resolver } from "@as4-411/resolver";
|
||||
import type { ResolveRequest } from "@as4-411/core";
|
||||
|
||||
export function createResolverRouter(resolver: Resolver): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/v1/resolve", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as ResolveRequest;
|
||||
if (!body?.identifiers?.length) {
|
||||
res.status(400).json({ error: "identifiers required and must be non-empty" });
|
||||
return;
|
||||
}
|
||||
const result = await resolver.resolve(body);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(503).json({
|
||||
error: "Resolution failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/bulk-resolve", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { requests } = req.body as { requests?: ResolveRequest[] };
|
||||
if (!Array.isArray(requests)) {
|
||||
res.status(400).json({ error: "requests array required" });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.all(requests.map((r) => resolver.resolve(r)));
|
||||
res.json({
|
||||
results,
|
||||
traceId: crypto.randomUUID(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({
|
||||
error: "Bulk resolution failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
20
packages/api/rest/src/routes/system.ts
Normal file
20
packages/api/rest/src/routes/system.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
|
||||
export function createSystemRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/v1/health", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: "0.1.0",
|
||||
checks: {},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/v1/metrics", (_req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.send("# as4-411 metrics (placeholder)\n");
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
15
packages/api/rest/src/server.ts
Normal file
15
packages/api/rest/src/server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from "./app.js";
|
||||
import { Resolver } from "@as4-411/resolver";
|
||||
import { InMemoryDirectoryStore } from "@as4-411/storage";
|
||||
import { InMemoryResolveCache } from "@as4-411/resolver";
|
||||
|
||||
const store = new InMemoryDirectoryStore();
|
||||
const cache = new InMemoryResolveCache();
|
||||
const resolver = new Resolver({ store, cache });
|
||||
|
||||
const app = createApp(resolver, store);
|
||||
const port = Number(process.env.PORT) || 4110;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`as4-411 API listening on http://localhost:${port}`);
|
||||
});
|
||||
16
packages/api/rest/tsconfig.json
Normal file
16
packages/api/rest/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user