Skip to content

Commit ea537a2

Browse files
authored
feat: calendar (#774)
* feat: calendar * fix: coderabbit suggestions * chore: add calendar * fix: cal build * fix: env var * fix: env var * feat: curr time
1 parent 7c471b7 commit ea537a2

117 files changed

Lines changed: 9814 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "calendar-api",
3+
"version": "1.0.0",
4+
"description": "W3DS auth and eVault-backed calendar events API",
5+
"main": "dist/index.js",
6+
"scripts": {
7+
"start": "node dist/index.js",
8+
"dev": "ts-node src/index.ts",
9+
"build": "tsc"
10+
},
11+
"dependencies": {
12+
"cors": "^2.8.5",
13+
"dotenv": "^16.4.5",
14+
"express": "^4.18.2",
15+
"graphql-request": "^6.1.0",
16+
"jsonwebtoken": "^9.0.2",
17+
"signature-validator": "workspace:*",
18+
"uuid": "^9.0.1",
19+
"axios": "^1.6.7"
20+
},
21+
"devDependencies": {
22+
"@types/cors": "^2.8.17",
23+
"@types/express": "^4.17.21",
24+
"@types/jsonwebtoken": "^9.0.5",
25+
"@types/node": "^20.11.19",
26+
"@types/uuid": "^9.0.8",
27+
"ts-node": "^10.9.2",
28+
"typescript": "^5.3.3"
29+
}
30+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const CALENDAR_EVENT_ONTOLOGY_ID =
2+
"880e8400-e29b-41d4-a716-446655440099";
3+
4+
const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
5+
6+
export interface StoredSession {
7+
createdAt: number;
8+
}
9+
10+
export const sessionStore = new Map<string, StoredSession>();
11+
12+
export function addSession(sessionId: string): void {
13+
sessionStore.set(sessionId, { createdAt: Date.now() });
14+
}
15+
16+
export function isSessionValid(sessionId: string): boolean {
17+
const s = sessionStore.get(sessionId);
18+
if (!s) return false;
19+
if (Date.now() - s.createdAt > SESSION_TTL_MS) {
20+
sessionStore.delete(sessionId);
21+
return false;
22+
}
23+
sessionStore.delete(sessionId); // one-time use
24+
return true;
25+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Request, Response } from "express";
2+
import { EventEmitter } from "events";
3+
import { v4 as uuidv4 } from "uuid";
4+
import jwt from "jsonwebtoken";
5+
import { verifySignature } from "signature-validator";
6+
import { isVersionValid } from "../utils/version";
7+
import { addSession, isSessionValid } from "../constants";
8+
9+
const MIN_REQUIRED_VERSION = "0.4.0";
10+
const JWT_EXPIRES_IN = "7d";
11+
12+
export class AuthController {
13+
private eventEmitter = new EventEmitter();
14+
15+
getOffer = async (_req: Request, res: Response) => {
16+
console.log("[auth] GET /api/auth/offer hit");
17+
const baseUrl = process.env.NEXT_PUBLIC_CALENDAR_APP_URL;
18+
if (!baseUrl) {
19+
console.error("[auth] NEXT_PUBLIC_CALENDAR_APP_URL is not set");
20+
return res.status(500).json({ error: "Server configuration error: NEXT_PUBLIC_CALENDAR_APP_URL not set" });
21+
}
22+
23+
let redirectUri: string;
24+
try {
25+
redirectUri = new URL("/api/auth", baseUrl).toString();
26+
} catch (err) {
27+
console.error("[auth] Invalid NEXT_PUBLIC_CALENDAR_APP_URL:", baseUrl, err);
28+
return res.status(500).json({ error: "Server configuration error: invalid base URL" });
29+
}
30+
31+
const session = uuidv4();
32+
addSession(session);
33+
const offer = `w3ds://auth?redirect=${encodeURIComponent(redirectUri)}&session=${session}&platform=${encodeURIComponent(baseUrl)}`;
34+
console.log("[auth] offer created, redirectUri=", redirectUri, "platform=", baseUrl);
35+
res.json({ uri: offer, sessionId: session });
36+
};
37+
38+
sseStream = async (req: Request, res: Response) => {
39+
const { id } = req.params;
40+
console.log("[auth] GET /api/auth/sessions/:id hit, sessionId=", id);
41+
res.writeHead(200, {
42+
"Content-Type": "text/event-stream",
43+
"Cache-Control": "no-cache",
44+
Connection: "keep-alive",
45+
"Access-Control-Allow-Origin": "*",
46+
});
47+
const handler = (data: unknown) => {
48+
res.write(`data: ${JSON.stringify(data)}\n\n`);
49+
};
50+
this.eventEmitter.on(id, handler);
51+
const heartbeat = setInterval(() => {
52+
try {
53+
res.write(": heartbeat\n\n");
54+
} catch {
55+
clearInterval(heartbeat);
56+
}
57+
}, 30000);
58+
req.on("close", () => {
59+
clearInterval(heartbeat);
60+
this.eventEmitter.off(id, handler);
61+
res.end();
62+
});
63+
};
64+
65+
login = async (req: Request, res: Response) => {
66+
console.log("[auth] POST /api/auth hit");
67+
try {
68+
const { ename, session, signature, appVersion } = req.body;
69+
console.log("[auth] body: ename=", ename, "session=", session?.slice(0, 8) + "...", "appVersion=", appVersion, "signature present=", !!signature);
70+
71+
if (!ename) {
72+
console.log("[auth] reject: ename missing");
73+
return res.status(400).json({ error: "ename is required" });
74+
}
75+
if (!session) {
76+
console.log("[auth] reject: session missing");
77+
return res.status(400).json({ error: "session is required" });
78+
}
79+
if (!signature) {
80+
console.log("[auth] reject: signature missing");
81+
return res.status(400).json({ error: "signature is required" });
82+
}
83+
84+
if (!isSessionValid(session)) {
85+
console.log("[auth] reject: invalid or expired session");
86+
return res
87+
.status(400)
88+
.json({ error: "Invalid or expired session", message: "Please request a new login offer." });
89+
}
90+
91+
if (!appVersion || !isVersionValid(appVersion, MIN_REQUIRED_VERSION)) {
92+
console.log("[auth] reject: app version too old", appVersion);
93+
return res.status(400).json({
94+
error: "App version too old",
95+
message: `Please update eID Wallet to version ${MIN_REQUIRED_VERSION} or later.`,
96+
});
97+
}
98+
99+
const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL;
100+
if (!registryBaseUrl) {
101+
console.log("[auth] reject: PUBLIC_REGISTRY_URL not set");
102+
return res.status(500).json({ error: "Server configuration error" });
103+
}
104+
105+
console.log("[auth] verifying signature with registry", registryBaseUrl);
106+
const verificationResult = await verifySignature({
107+
eName: ename,
108+
signature,
109+
payload: session,
110+
registryBaseUrl,
111+
});
112+
113+
if (!verificationResult.valid) {
114+
console.log("[auth] reject: signature invalid", verificationResult.error);
115+
return res.status(401).json({
116+
error: "Invalid signature",
117+
message: verificationResult.error,
118+
});
119+
}
120+
121+
const secret = process.env.JWT_SECRET || "calendar-api-dev-secret";
122+
const token = jwt.sign(
123+
{ ename },
124+
secret,
125+
{ expiresIn: JWT_EXPIRES_IN }
126+
);
127+
128+
console.log("[auth] login success, ename=", ename);
129+
this.eventEmitter.emit(session, { token });
130+
res.status(200).json({ token });
131+
} catch (error) {
132+
console.error("[auth] login error:", error);
133+
res.status(500).json({ error: "Internal server error" });
134+
}
135+
};
136+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Response } from "express";
2+
import { EVaultService } from "../services/EVaultService";
3+
import { AuthenticatedRequest } from "../middleware/auth";
4+
5+
const evaultService = new EVaultService();
6+
7+
export class EventsController {
8+
list = async (req: AuthenticatedRequest, res: Response) => {
9+
try {
10+
const ename = req.user?.ename;
11+
if (!ename) {
12+
res.status(401).json({ error: "Unauthorized" });
13+
return;
14+
}
15+
const first = Math.min(
16+
parseInt(String(req.query.first), 10) || 100,
17+
500
18+
);
19+
const after = (req.query.after as string) || undefined;
20+
const events = await evaultService.listEvents(ename, first, after);
21+
res.json(events);
22+
} catch (error) {
23+
console.error("List events error:", error);
24+
res.status(500).json({
25+
error: "Failed to list events",
26+
message: error instanceof Error ? error.message : "Unknown error",
27+
});
28+
}
29+
};
30+
31+
create = async (req: AuthenticatedRequest, res: Response) => {
32+
try {
33+
const ename = req.user?.ename;
34+
if (!ename) {
35+
res.status(401).json({ error: "Unauthorized" });
36+
return;
37+
}
38+
const { title, color, start, end } = req.body;
39+
if (!title || !start || !end) {
40+
res.status(400).json({
41+
error: "Missing required fields",
42+
message: "title, start, and end are required",
43+
});
44+
return;
45+
}
46+
const event = await evaultService.createEvent(ename, {
47+
title,
48+
color: color ?? "",
49+
start,
50+
end,
51+
});
52+
res.status(201).json(event);
53+
} catch (error) {
54+
console.error("Create event error:", error);
55+
res.status(500).json({
56+
error: "Failed to create event",
57+
message: error instanceof Error ? error.message : "Unknown error",
58+
});
59+
}
60+
};
61+
62+
update = async (req: AuthenticatedRequest, res: Response) => {
63+
try {
64+
const ename = req.user?.ename;
65+
if (!ename) {
66+
res.status(401).json({ error: "Unauthorized" });
67+
return;
68+
}
69+
const id = req.params.id;
70+
const { title, color, start, end } = req.body;
71+
const payload: Record<string, string> = {};
72+
if (title !== undefined) payload.title = title;
73+
if (color !== undefined) payload.color = color;
74+
if (start !== undefined) payload.start = start;
75+
if (end !== undefined) payload.end = end;
76+
if (Object.keys(payload).length === 0) {
77+
res.status(400).json({ error: "No fields to update" });
78+
return;
79+
}
80+
const event = await evaultService.updateEvent(ename, id, payload);
81+
res.json(event);
82+
} catch (error) {
83+
console.error("Update event error:", error);
84+
res.status(500).json({
85+
error: "Failed to update event",
86+
message: error instanceof Error ? error.message : "Unknown error",
87+
});
88+
}
89+
};
90+
91+
remove = async (req: AuthenticatedRequest, res: Response) => {
92+
try {
93+
const ename = req.user?.ename;
94+
if (!ename) {
95+
res.status(401).json({ error: "Unauthorized" });
96+
return;
97+
}
98+
const id = req.params.id;
99+
await evaultService.removeEvent(ename, id);
100+
res.status(204).send();
101+
} catch (error) {
102+
console.error("Remove event error:", error);
103+
res.status(500).json({
104+
error: "Failed to delete event",
105+
message: error instanceof Error ? error.message : "Unknown error",
106+
});
107+
}
108+
}
109+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import dotenv from "dotenv";
2+
import fs from "fs";
3+
import path from "path";
4+
import express from "express";
5+
import cors from "cors";
6+
7+
// Load .env: try monorepo root, then calendar-api parent, then cwd (no fallback)
8+
const candidates = [
9+
path.resolve(__dirname, "../../../.env"), // repo root from dist/
10+
path.resolve(__dirname, "../../.env"), // repo root from src/ or platforms/.env
11+
path.resolve(process.cwd(), ".env"),
12+
];
13+
const envPath = candidates.find((p) => fs.existsSync(p));
14+
if (envPath) {
15+
dotenv.config({ path: envPath });
16+
} else {
17+
console.warn(
18+
"No .env found at",
19+
candidates.join(", "),
20+
"- env vars must be set by shell or elsewhere"
21+
);
22+
}
23+
import { AuthController } from "./controllers/AuthController";
24+
import { EventsController } from "./controllers/EventsController";
25+
import { authMiddleware } from "./middleware/auth";
26+
27+
const app = express();
28+
const port = process.env.PORT ?? 4001;
29+
30+
app.use(
31+
cors({
32+
origin: true,
33+
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
34+
allowedHeaders: ["Content-Type", "Authorization"],
35+
credentials: true,
36+
})
37+
);
38+
app.use(express.json());
39+
40+
const authController = new AuthController();
41+
const eventsController = new EventsController();
42+
43+
app.get("/api/auth/offer", authController.getOffer);
44+
app.get("/api/auth/sessions/:id", authController.sseStream);
45+
app.post("/api/auth", authController.login);
46+
47+
app.get("/api/events", authMiddleware, eventsController.list);
48+
app.post("/api/events", authMiddleware, eventsController.create);
49+
app.patch("/api/events/:id", authMiddleware, eventsController.update);
50+
app.delete("/api/events/:id", authMiddleware, eventsController.remove);
51+
52+
app.listen(port, () => {
53+
console.log(`Calendar API running on port ${port}`);
54+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Request, Response, NextFunction } from "express";
2+
import jwt from "jsonwebtoken";
3+
4+
export interface AuthPayload {
5+
ename: string;
6+
}
7+
8+
export interface AuthenticatedRequest extends Request {
9+
user?: AuthPayload;
10+
}
11+
12+
export function authMiddleware(
13+
req: AuthenticatedRequest,
14+
res: Response,
15+
next: NextFunction
16+
): void {
17+
const authHeader = req.headers.authorization;
18+
if (!authHeader?.startsWith("Bearer ")) {
19+
res.status(401).json({ error: "Missing or invalid Authorization header" });
20+
return;
21+
}
22+
23+
const token = authHeader.slice(7);
24+
const secret = process.env.JWT_SECRET || "calendar-api-dev-secret";
25+
26+
try {
27+
const decoded = jwt.verify(token, secret) as AuthPayload;
28+
if (!decoded.ename) {
29+
res.status(401).json({ error: "Invalid token payload" });
30+
return;
31+
}
32+
req.user = { ename: decoded.ename };
33+
next();
34+
} catch {
35+
res.status(401).json({ error: "Invalid or expired token" });
36+
}
37+
}

0 commit comments

Comments
 (0)