From c0ef747bbb2a6fa60a558a84e466a052e0761a92 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 3 May 2026 10:15:00 +0530 Subject: [PATCH 01/24] chore(awareness-service): scaffold api package Add the awareness-service workspace under services/ with the api package: TypeORM entities (Packet, Consumer, AccessApplication, ApiKey, Subscription, Delivery, DeadLetter), Postgres data-source, config loader, pagination cursor and backoff utilities, and an Express bootstrap with a health endpoint. --- services/awareness-service/api/nodemon.json | 6 ++ services/awareness-service/api/package.json | 41 ++++++++++++++ services/awareness-service/api/src/config.ts | 40 ++++++++++++++ .../api/src/database/data-source.ts | 37 +++++++++++++ .../database/entities/AccessApplication.ts | 45 +++++++++++++++ .../api/src/database/entities/ApiKey.ts | 38 +++++++++++++ .../api/src/database/entities/Consumer.ts | 42 ++++++++++++++ .../api/src/database/entities/DeadLetter.ts | 52 ++++++++++++++++++ .../api/src/database/entities/Delivery.ts | 55 +++++++++++++++++++ .../api/src/database/entities/Packet.ts | 48 ++++++++++++++++ .../api/src/database/entities/Subscription.ts | 47 ++++++++++++++++ services/awareness-service/api/src/index.ts | 27 +++++++++ services/awareness-service/api/src/types.ts | 24 ++++++++ .../api/src/utils/backoff.ts | 24 ++++++++ .../awareness-service/api/src/utils/cursor.ts | 30 ++++++++++ services/awareness-service/api/tsconfig.json | 19 +++++++ services/awareness-service/package.json | 11 ++++ 17 files changed, 586 insertions(+) create mode 100644 services/awareness-service/api/nodemon.json create mode 100644 services/awareness-service/api/package.json create mode 100644 services/awareness-service/api/src/config.ts create mode 100644 services/awareness-service/api/src/database/data-source.ts create mode 100644 services/awareness-service/api/src/database/entities/AccessApplication.ts create mode 100644 services/awareness-service/api/src/database/entities/ApiKey.ts create mode 100644 services/awareness-service/api/src/database/entities/Consumer.ts create mode 100644 services/awareness-service/api/src/database/entities/DeadLetter.ts create mode 100644 services/awareness-service/api/src/database/entities/Delivery.ts create mode 100644 services/awareness-service/api/src/database/entities/Packet.ts create mode 100644 services/awareness-service/api/src/database/entities/Subscription.ts create mode 100644 services/awareness-service/api/src/index.ts create mode 100644 services/awareness-service/api/src/types.ts create mode 100644 services/awareness-service/api/src/utils/backoff.ts create mode 100644 services/awareness-service/api/src/utils/cursor.ts create mode 100644 services/awareness-service/api/tsconfig.json create mode 100644 services/awareness-service/package.json diff --git a/services/awareness-service/api/nodemon.json b/services/awareness-service/api/nodemon.json new file mode 100644 index 000000000..6e8151032 --- /dev/null +++ b/services/awareness-service/api/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts,json", + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node src/index.ts" +} diff --git a/services/awareness-service/api/package.json b/services/awareness-service/api/package.json new file mode 100644 index 000000000..7ea1263bb --- /dev/null +++ b/services/awareness-service/api/package.json @@ -0,0 +1,41 @@ +{ + "name": "awareness-service-api", + "version": "1.0.0", + "description": "Awareness as a Service API", + "main": "src/index.ts", + "scripts": { + "start": "node dist/index.js", + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", + "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", + "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", + "backfill": "ts-node src/scripts/backfill-neo4j.ts", + "seed:catchall": "ts-node src/scripts/seed-catchall.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "neo4j-driver": "^5.28.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "signature-validator": "workspace:*", + "typeorm": "^0.3.24", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.11.24", + "@types/pg": "^8.11.2", + "@types/uuid": "^9.0.8", + "nodemon": "^3.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/services/awareness-service/api/src/config.ts b/services/awareness-service/api/src/config.ts new file mode 100644 index 000000000..4668685cc --- /dev/null +++ b/services/awareness-service/api/src/config.ts @@ -0,0 +1,40 @@ +import { config as loadEnv } from "dotenv"; +import path from "path"; + +loadEnv({ path: path.resolve(__dirname, "../../../../.env") }); + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export const config = { + /** Postgres connection string for the AaaS database. */ + databaseUrl: process.env.AWARENESS_DATABASE_URL ?? "", + apiPort: parseInt(process.env.AWARENESS_API_PORT ?? "4100", 10), + /** Shared secret evault-core must present on POST /ingest. */ + ingestSecret: process.env.AWARENESS_INGEST_SECRET ?? "", + /** Registry used both for catch-all seeding and W3DS signature checks. */ + registryUrl: + process.env.PUBLIC_REGISTRY_URL ?? process.env.REGISTRY_URL ?? "", + /** Comma-separated eNames allowed to act as portal admins. */ + adminEnames: (process.env.AAAS_ADMIN_ENAMES ?? "") + .split(",") + .map((e) => e.trim()) + .filter(Boolean), + /** Secret used to sign portal session JWTs. */ + jwtSecret: process.env.AAAS_JWT_SECRET ?? "awareness-dev-secret", + maxAttempts: parseInt(process.env.AWARENESS_MAX_ATTEMPTS ?? "8", 10), + deliveryPollMs: parseInt( + process.env.AWARENESS_DELIVERY_POLL_MS ?? "2000", + 10, + ), + /** Public base URL of the AaaS API, used to build W3DS auth callbacks. */ + publicUrl: process.env.AWARENESS_PUBLIC_URL ?? "http://localhost:4100", + dbCaCert: process.env.DB_CA_CERT, +}; + +export { required }; diff --git a/services/awareness-service/api/src/database/data-source.ts b/services/awareness-service/api/src/database/data-source.ts new file mode 100644 index 000000000..2e184c1b0 --- /dev/null +++ b/services/awareness-service/api/src/database/data-source.ts @@ -0,0 +1,37 @@ +import "reflect-metadata"; +import path from "path"; +import { DataSource } from "typeorm"; +import { config } from "../config"; +import { AccessApplication } from "./entities/AccessApplication"; +import { ApiKey } from "./entities/ApiKey"; +import { Consumer } from "./entities/Consumer"; +import { DeadLetter } from "./entities/DeadLetter"; +import { Delivery } from "./entities/Delivery"; +import { Packet } from "./entities/Packet"; +import { Subscription } from "./entities/Subscription"; + +export const AppDataSource = new DataSource({ + type: "postgres", + url: config.databaseUrl, + synchronize: false, + logging: process.env.NODE_ENV === "development", + entities: [ + Packet, + Consumer, + AccessApplication, + ApiKey, + Subscription, + Delivery, + DeadLetter, + ], + migrations: [path.join(__dirname, "migrations", "*.{ts,js}")], + ssl: config.dbCaCert + ? { rejectUnauthorized: false, ca: config.dbCaCert } + : false, + extra: { + max: 10, + min: 2, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }, +}); diff --git a/services/awareness-service/api/src/database/entities/AccessApplication.ts b/services/awareness-service/api/src/database/entities/AccessApplication.ts new file mode 100644 index 000000000..fadc398f9 --- /dev/null +++ b/services/awareness-service/api/src/database/entities/AccessApplication.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +export type ApplicationStatus = "pending" | "approved" | "rejected"; + +/** + * An access request submitted by a platform through the portal. Reviewed by an + * admin (eName in AAAS_ADMIN_ENAMES). + */ +@Entity("access_applications") +export class AccessApplication { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Index("idx_applications_consumer") + @Column({ type: "uuid" }) + consumerId!: string; + + @Column({ type: "text", nullable: true }) + justification!: string | null; + + /** Ontologies the applicant says they need - informational only. */ + @Column({ type: "text", array: true, default: () => "'{}'" }) + requestedOntologies!: string[]; + + @Column({ type: "varchar", default: "pending" }) + status!: ApplicationStatus; + + @Column({ type: "varchar", nullable: true }) + reviewedByEname!: string | null; + + @Column({ type: "text", nullable: true }) + reviewNote!: string | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @Column({ type: "timestamptz", nullable: true }) + reviewedAt!: Date | null; +} diff --git a/services/awareness-service/api/src/database/entities/ApiKey.ts b/services/awareness-service/api/src/database/entities/ApiKey.ts new file mode 100644 index 000000000..03c60632d --- /dev/null +++ b/services/awareness-service/api/src/database/entities/ApiKey.ts @@ -0,0 +1,38 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +/** + * A long-lived API key issued to an approved consumer. Only the SHA-256 hash is + * stored; the plaintext key is shown to the consumer exactly once on creation. + */ +@Entity("api_keys") +export class ApiKey { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Index("idx_api_keys_consumer") + @Column({ type: "uuid" }) + consumerId!: string; + + @Index("idx_api_keys_hash", { unique: true }) + @Column({ type: "varchar" }) + keyHash!: string; + + /** First chars of the plaintext key, for display in the portal. */ + @Column({ type: "varchar" }) + keyPrefix!: string; + + @Column({ type: "boolean", default: false }) + revoked!: boolean; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @Column({ type: "timestamptz", nullable: true }) + lastUsedAt!: Date | null; +} diff --git a/services/awareness-service/api/src/database/entities/Consumer.ts b/services/awareness-service/api/src/database/entities/Consumer.ts new file mode 100644 index 000000000..a68d4f28f --- /dev/null +++ b/services/awareness-service/api/src/database/entities/Consumer.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +export type ConsumerStatus = "pending" | "approved" | "rejected" | "revoked"; + +/** + * A platform that consumes awareness packets. Created when a platform applies + * for access via the portal, or auto-seeded for backward-compat catch-all. + */ +@Entity("consumers") +export class Consumer { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Index("idx_consumers_ename", { unique: true }) + @Column({ type: "varchar" }) + ename!: string; + + @Column({ type: "varchar", nullable: true }) + name!: string | null; + + @Column({ type: "varchar", nullable: true }) + contactEmail!: string | null; + + @Column({ type: "varchar", default: "pending" }) + status!: ConsumerStatus; + + /** Platform base URL; default webhook target is `/api/webhook`. */ + @Column({ type: "varchar", nullable: true }) + webhookBaseUrl!: string | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @Column({ type: "timestamptz", nullable: true }) + approvedAt!: Date | null; +} diff --git a/services/awareness-service/api/src/database/entities/DeadLetter.ts b/services/awareness-service/api/src/database/entities/DeadLetter.ts new file mode 100644 index 000000000..5f5a70069 --- /dev/null +++ b/services/awareness-service/api/src/database/entities/DeadLetter.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +/** + * A delivery that exhausted all retry attempts. Surfaced in the admin portal; + * an admin can replay it, which re-queues a fresh Delivery. + */ +@Entity("dead_letters") +export class DeadLetter { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid" }) + deliveryId!: string; + + @Column({ type: "uuid" }) + subscriptionId!: string; + + @Column({ type: "varchar" }) + packetId!: string; + + @Column({ type: "uuid" }) + consumerId!: string; + + /** The exact body that failed to deliver. */ + @Column({ type: "jsonb" }) + payload!: Record; + + @Column({ type: "varchar" }) + targetUrl!: string; + + @Column({ type: "int" }) + totalAttempts!: number; + + @Column({ type: "text", nullable: true }) + lastError!: string | null; + + @Column({ type: "int", nullable: true }) + lastResponseStatus!: number | null; + + @Index("idx_dead_letters_resolved") + @Column({ type: "boolean", default: false }) + resolved!: boolean; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; +} diff --git a/services/awareness-service/api/src/database/entities/Delivery.ts b/services/awareness-service/api/src/database/entities/Delivery.ts new file mode 100644 index 000000000..d16b6d33b --- /dev/null +++ b/services/awareness-service/api/src/database/entities/Delivery.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, +} from "typeorm"; + +export type DeliveryStatus = + | "pending" + | "delivering" + | "delivered" + | "failed"; + +/** + * A queued webhook delivery of one packet to one subscription. The unique + * (subscriptionId, packetId) constraint makes ingest idempotent if evault-core + * retries a POST. + */ +@Entity("deliveries") +@Unique("uq_delivery_subscription_packet", ["subscriptionId", "packetId"]) +export class Delivery { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Index("idx_deliveries_subscription") + @Column({ type: "uuid" }) + subscriptionId!: string; + + @Column({ type: "varchar" }) + packetId!: string; + + @Column({ type: "varchar", default: "pending" }) + status!: DeliveryStatus; + + @Column({ type: "int", default: 0 }) + attempts!: number; + + @Index("idx_deliveries_next_attempt") + @Column({ type: "timestamptz", default: () => "now()" }) + nextAttemptAt!: Date; + + @Column({ type: "text", nullable: true }) + lastError!: string | null; + + @Column({ type: "int", nullable: true }) + lastResponseStatus!: number | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @Column({ type: "timestamptz", nullable: true }) + deliveredAt!: Date | null; +} diff --git a/services/awareness-service/api/src/database/entities/Packet.ts b/services/awareness-service/api/src/database/entities/Packet.ts new file mode 100644 index 000000000..09b3d4ff9 --- /dev/null +++ b/services/awareness-service/api/src/database/entities/Packet.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from "typeorm"; + +export type PacketOperation = "create" | "update" | "delete"; + +/** + * A single awareness packet ingested from an eVault. `id` is the MetaEnvelope + * id supplied by evault-core, so re-ingestion of the same envelope upserts. + */ +@Entity("packets") +@Index("idx_packets_ontology_received", ["ontology", "receivedAt"]) +@Index("idx_packets_received_id", ["receivedAt", "id"]) +export class Packet { + @PrimaryColumn({ type: "varchar" }) + id!: string; + + /** The MetaEnvelope ontology / schema id (source payload `schemaId`). */ + @Index("idx_packets_ontology") + @Column({ type: "varchar" }) + ontology!: string; + + @Index("idx_packets_evault_pubkey") + @Column({ type: "varchar", nullable: true }) + evaultPublicKey!: string | null; + + /** The user's W3ID (eName) the envelope belongs to. */ + @Index("idx_packets_w3id") + @Column({ type: "varchar", nullable: true }) + w3id!: string | null; + + @Column({ type: "jsonb", nullable: true }) + data!: Record | null; + + @Column({ type: "varchar", default: "create" }) + operation!: PacketOperation; + + @Index("idx_packets_received") + @Column({ type: "timestamptz", default: () => "now()" }) + receivedAt!: Date; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; +} diff --git a/services/awareness-service/api/src/database/entities/Subscription.ts b/services/awareness-service/api/src/database/entities/Subscription.ts new file mode 100644 index 000000000..bd134ea1a --- /dev/null +++ b/services/awareness-service/api/src/database/entities/Subscription.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +/** + * A webhook subscription owned by a consumer. Empty filter arrays mean "match + * everything". `isCatchAll` marks the backward-compat subscriptions seeded for + * platforms that were registered before AaaS existed. + */ +@Entity("subscriptions") +export class Subscription { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Index("idx_subscriptions_consumer") + @Column({ type: "uuid" }) + consumerId!: string; + + /** Where matching packets are POSTed. */ + @Column({ type: "varchar" }) + targetUrl!: string; + + /** Ontologies to match; empty = all ontologies. */ + @Column({ type: "text", array: true, default: () => "'{}'" }) + ontologyFilter!: string[]; + + /** eVaults (w3id / evaultPublicKey) to match; empty = all eVaults. */ + @Column({ type: "text", array: true, default: () => "'{}'" }) + evaultFilter!: string[]; + + @Column({ type: "boolean", default: false }) + isCatchAll!: boolean; + + @Column({ type: "boolean", default: true }) + active!: boolean; + + /** Optional shared secret; when set, payloads are signed with HMAC-SHA256. */ + @Column({ type: "varchar", nullable: true }) + secret!: string | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; +} diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts new file mode 100644 index 000000000..4de37bb48 --- /dev/null +++ b/services/awareness-service/api/src/index.ts @@ -0,0 +1,27 @@ +import "reflect-metadata"; +import cors from "cors"; +import express from "express"; +import { config } from "./config"; +import { AppDataSource } from "./database/data-source"; + +async function start(): Promise { + await AppDataSource.initialize(); + console.log("[aaas] database connected"); + + const app = express(); + app.use(cors()); + app.use(express.json({ limit: "5mb" })); + + app.get("/health", (_req, res) => { + res.json({ status: "ok", service: "awareness-service" }); + }); + + app.listen(config.apiPort, () => { + console.log(`[aaas] API listening on :${config.apiPort}`); + }); +} + +start().catch((err) => { + console.error("[aaas] failed to start:", err); + process.exit(1); +}); diff --git a/services/awareness-service/api/src/types.ts b/services/awareness-service/api/src/types.ts new file mode 100644 index 000000000..f76d2fe1d --- /dev/null +++ b/services/awareness-service/api/src/types.ts @@ -0,0 +1,24 @@ +import type { Consumer } from "./database/entities/Consumer"; + +/** The raw payload evault-core POSTs to /ingest (and the body delivered to subscribers). */ +export interface AwarenessPayload { + id: string; + w3id?: string | null; + evaultPublicKey?: string | null; + data?: Record | null; + schemaId: string; + operation?: "create" | "update" | "delete"; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + /** Set by consumerAuth middleware once an API key is verified. */ + consumer?: Consumer; + /** Set by portalAuth/adminAuth once a W3DS session JWT is verified. */ + ename?: string; + isAdmin?: boolean; + } + } +} diff --git a/services/awareness-service/api/src/utils/backoff.ts b/services/awareness-service/api/src/utils/backoff.ts new file mode 100644 index 000000000..f1499260e --- /dev/null +++ b/services/awareness-service/api/src/utils/backoff.ts @@ -0,0 +1,24 @@ +/** + * Exponential backoff schedule (in milliseconds) for failed webhook deliveries. + * Index = attempt number that just failed (1-based). Beyond the table the last + * value is reused. + */ +const SCHEDULE_MS = [ + 30_000, // after attempt 1: 30s + 60_000, // after attempt 2: 1m + 120_000, // after attempt 3: 2m + 300_000, // after attempt 4: 5m + 900_000, // after attempt 5: 15m + 3_600_000, // after attempt 6: 1h + 21_600_000, // after attempt 7: 6h + 86_400_000, // after attempt 8: 24h +]; + +/** Returns the Date at which a delivery should next be attempted. */ +export function nextAttemptAt(attempts: number, from: Date = new Date()): Date { + const idx = Math.min(Math.max(attempts, 1), SCHEDULE_MS.length) - 1; + const base = SCHEDULE_MS[idx]; + // +/-10% jitter to avoid thundering-herd retries. + const jitter = base * (Math.random() * 0.2 - 0.1); + return new Date(from.getTime() + base + jitter); +} diff --git a/services/awareness-service/api/src/utils/cursor.ts b/services/awareness-service/api/src/utils/cursor.ts new file mode 100644 index 000000000..5a9f5e87b --- /dev/null +++ b/services/awareness-service/api/src/utils/cursor.ts @@ -0,0 +1,30 @@ +/** + * Opaque pagination cursor encoding a (receivedAt, id) pair. Packets are ordered + * by (receivedAt, id) so this pair uniquely positions a row in the result set. + */ +export interface PacketCursor { + receivedAt: string; + id: string; +} + +export function encodeCursor(cursor: PacketCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +export function decodeCursor(raw: string): PacketCursor | null { + try { + const parsed = JSON.parse( + Buffer.from(raw, "base64url").toString("utf8"), + ); + if ( + parsed && + typeof parsed.receivedAt === "string" && + typeof parsed.id === "string" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} diff --git a/services/awareness-service/api/tsconfig.json b/services/awareness-service/api/tsconfig.json new file mode 100644 index 000000000..706600677 --- /dev/null +++ b/services/awareness-service/api/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/awareness-service/package.json b/services/awareness-service/package.json new file mode 100644 index 000000000..b4a7314ff --- /dev/null +++ b/services/awareness-service/package.json @@ -0,0 +1,11 @@ +{ + "name": "awareness-service", + "version": "1.0.0", + "private": true, + "description": "Awareness as a Service - awareness packet ingestion, polling and webhook fanout", + "scripts": { + "dev": "pnpm --filter awareness-service-api dev", + "build": "pnpm --filter awareness-service-api build", + "start": "pnpm --filter awareness-service-api start" + } +} From de745d7b174d53821dd723cb09f66911cbf82a2d Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 4 May 2026 14:30:00 +0530 Subject: [PATCH 02/24] feat(awareness-service): ingest endpoint and webhook delivery engine Add POST /ingest as the single sink for evault-core awareness packets, authenticated by a shared secret. IngestService upserts the packet and queues one delivery per matching subscription (idempotent via a unique constraint). SubscriptionMatcher resolves matches by ontology and eVault filters. The DeliveryEngine drains the queue with FOR UPDATE SKIP LOCKED batching, exponential backoff retries, optional HMAC signing, and a dead-letter table. --- .../api/src/controllers/IngestController.ts | 42 +++++ services/awareness-service/api/src/index.ts | 7 + .../api/src/services/DeliveryEngine.ts | 172 ++++++++++++++++++ .../api/src/services/IngestService.ts | 64 +++++++ .../api/src/services/SubscriptionMatcher.ts | 35 ++++ 5 files changed, 320 insertions(+) create mode 100644 services/awareness-service/api/src/controllers/IngestController.ts create mode 100644 services/awareness-service/api/src/services/DeliveryEngine.ts create mode 100644 services/awareness-service/api/src/services/IngestService.ts create mode 100644 services/awareness-service/api/src/services/SubscriptionMatcher.ts diff --git a/services/awareness-service/api/src/controllers/IngestController.ts b/services/awareness-service/api/src/controllers/IngestController.ts new file mode 100644 index 000000000..87ae8b2cd --- /dev/null +++ b/services/awareness-service/api/src/controllers/IngestController.ts @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { config } from "../config"; +import { IngestService } from "../services/IngestService"; +import type { AwarenessPayload } from "../types"; + +/** + * POST /ingest - the single entry point evault-core POSTs every MetaEnvelope + * change to. Authenticated by the shared `x-ingest-secret` header. + */ +export function ingestRouter(): Router { + const router = Router(); + const service = new IngestService(); + + router.post("/ingest", async (req, res) => { + if ( + config.ingestSecret && + req.header("x-ingest-secret") !== config.ingestSecret + ) { + return res.status(401).json({ error: "invalid ingest secret" }); + } + + const body = req.body as AwarenessPayload; + if (!body?.id || !body?.schemaId) { + return res + .status(400) + .json({ error: "id and schemaId are required" }); + } + + try { + const result = await service.ingest(body); + console.log( + `[ingest] id=${body.id} schemaId=${body.schemaId} w3id=${body.w3id ?? ""} queued=${result.deliveriesQueued}`, + ); + return res.json({ ok: true, ...result }); + } catch (err) { + console.error("[ingest] failed:", err); + return res.status(500).json({ error: "ingest failed" }); + } + }); + + return router; +} diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts index 4de37bb48..f65885646 100644 --- a/services/awareness-service/api/src/index.ts +++ b/services/awareness-service/api/src/index.ts @@ -2,7 +2,9 @@ import "reflect-metadata"; import cors from "cors"; import express from "express"; import { config } from "./config"; +import { ingestRouter } from "./controllers/IngestController"; import { AppDataSource } from "./database/data-source"; +import { DeliveryEngine } from "./services/DeliveryEngine"; async function start(): Promise { await AppDataSource.initialize(); @@ -16,6 +18,11 @@ async function start(): Promise { res.json({ status: "ok", service: "awareness-service" }); }); + app.use(ingestRouter()); + + const deliveryEngine = new DeliveryEngine(); + deliveryEngine.start(); + app.listen(config.apiPort, () => { console.log(`[aaas] API listening on :${config.apiPort}`); }); diff --git a/services/awareness-service/api/src/services/DeliveryEngine.ts b/services/awareness-service/api/src/services/DeliveryEngine.ts new file mode 100644 index 000000000..fc8cf52b3 --- /dev/null +++ b/services/awareness-service/api/src/services/DeliveryEngine.ts @@ -0,0 +1,172 @@ +import crypto from "crypto"; +import axios from "axios"; +import { AppDataSource } from "../database/data-source"; +import { DeadLetter } from "../database/entities/DeadLetter"; +import { Delivery } from "../database/entities/Delivery"; +import { Packet } from "../database/entities/Packet"; +import { Subscription } from "../database/entities/Subscription"; +import { config } from "../config"; +import { nextAttemptAt } from "../utils/backoff"; +import type { AwarenessPayload } from "../types"; + +const BATCH_SIZE = 50; + +/** + * Background worker that drains the deliveries queue. Each tick atomically + * claims a batch of due deliveries (FOR UPDATE SKIP LOCKED so concurrent ticks + * never double-send), POSTs each to its subscription target, and either marks + * it delivered or reschedules it with exponential backoff. Once a delivery + * exhausts AWARENESS_MAX_ATTEMPTS it is written to the dead-letter table. + */ +export class DeliveryEngine { + private timer?: NodeJS.Timeout; + private running = false; + + start(): void { + this.timer = setInterval(() => { + void this.tick(); + }, config.deliveryPollMs); + console.log( + `[aaas] delivery engine started (poll ${config.deliveryPollMs}ms)`, + ); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + } + + private async tick(): Promise { + if (this.running) return; // skip overlapping ticks + this.running = true; + try { + const claimed = await this.claimBatch(); + for (const delivery of claimed) { + await this.attemptDelivery(delivery); + } + } catch (err) { + console.error("[aaas] delivery tick failed:", err); + } finally { + this.running = false; + } + } + + /** Atomically move a batch of due deliveries to `delivering`. */ + private async claimBatch(): Promise { + const rows = await AppDataSource.query( + `UPDATE deliveries SET status = 'delivering' + WHERE id IN ( + SELECT id FROM deliveries + WHERE status IN ('pending', 'failed') + AND "nextAttemptAt" <= now() + ORDER BY "nextAttemptAt" + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + RETURNING *`, + [BATCH_SIZE], + ); + return rows as Delivery[]; + } + + private async attemptDelivery(delivery: Delivery): Promise { + const subscription = await AppDataSource.getRepository( + Subscription, + ).findOne({ where: { id: delivery.subscriptionId } }); + const packet = await AppDataSource.getRepository(Packet).findOne({ + where: { id: delivery.packetId }, + }); + + if (!subscription || !packet) { + await this.fail( + delivery, + subscription, + "subscription or packet no longer exists", + null, + ); + return; + } + + const payload: AwarenessPayload = { + id: packet.id, + w3id: packet.w3id, + evaultPublicKey: packet.evaultPublicKey, + data: packet.data, + schemaId: packet.ontology, + }; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (subscription.secret) { + headers["x-aaas-signature"] = crypto + .createHmac("sha256", subscription.secret) + .update(JSON.stringify(payload)) + .digest("hex"); + } + + try { + const res = await axios.post(subscription.targetUrl, payload, { + headers, + timeout: 5000, + }); + await AppDataSource.getRepository(Delivery).update(delivery.id, { + status: "delivered", + attempts: delivery.attempts + 1, + deliveredAt: new Date(), + lastResponseStatus: res.status, + lastError: null, + }); + } catch (err: any) { + const responseStatus = err?.response?.status ?? null; + const message = + err?.message ?? "unknown webhook delivery failure"; + await this.fail(delivery, subscription, message, responseStatus, { + payload, + }); + } + } + + private async fail( + delivery: Delivery, + subscription: Subscription | null, + message: string, + responseStatus: number | null, + ctx?: { payload: AwarenessPayload }, + ): Promise { + const attempts = delivery.attempts + 1; + const deliveryRepo = AppDataSource.getRepository(Delivery); + + if (attempts >= config.maxAttempts) { + await deliveryRepo.update(delivery.id, { + status: "failed", + attempts, + lastError: message, + lastResponseStatus: responseStatus, + }); + await AppDataSource.getRepository(DeadLetter).insert({ + deliveryId: delivery.id, + subscriptionId: delivery.subscriptionId, + packetId: delivery.packetId, + consumerId: subscription?.consumerId ?? delivery.subscriptionId, + payload: (ctx?.payload ?? {}) as Record, + targetUrl: subscription?.targetUrl ?? "", + totalAttempts: attempts, + lastError: message, + lastResponseStatus: responseStatus, + resolved: false, + }); + console.warn( + `[aaas] delivery ${delivery.id} dead-lettered after ${attempts} attempts`, + ); + return; + } + + await deliveryRepo.update(delivery.id, { + status: "failed", + attempts, + lastError: message, + lastResponseStatus: responseStatus, + nextAttemptAt: nextAttemptAt(attempts), + }); + } +} diff --git a/services/awareness-service/api/src/services/IngestService.ts b/services/awareness-service/api/src/services/IngestService.ts new file mode 100644 index 000000000..7317677c0 --- /dev/null +++ b/services/awareness-service/api/src/services/IngestService.ts @@ -0,0 +1,64 @@ +import { AppDataSource } from "../database/data-source"; +import { Delivery } from "../database/entities/Delivery"; +import { Packet } from "../database/entities/Packet"; +import type { AwarenessPayload } from "../types"; +import { SubscriptionMatcher } from "./SubscriptionMatcher"; + +/** + * Persists an incoming awareness packet and queues a webhook delivery for every + * subscription that matches it. Re-ingesting the same packet is idempotent: the + * packet is upserted and duplicate deliveries are skipped by a unique + * (subscriptionId, packetId) constraint. + */ +export class IngestService { + private matcher = new SubscriptionMatcher(); + + async ingest( + payload: AwarenessPayload, + ): Promise<{ packetId: string; deliveriesQueued: number }> { + const packetRepo = AppDataSource.getRepository(Packet); + + const packet = packetRepo.create({ + id: payload.id, + ontology: payload.schemaId, + evaultPublicKey: payload.evaultPublicKey ?? null, + w3id: payload.w3id ?? null, + data: payload.data ?? null, + operation: payload.operation ?? "create", + receivedAt: new Date(), + }); + + await packetRepo.upsert(packet, ["id"]); + + const subscriptions = await this.matcher.match(packet); + if (subscriptions.length === 0) { + return { packetId: packet.id, deliveriesQueued: 0 }; + } + + const deliveryRepo = AppDataSource.getRepository(Delivery); + const rows = subscriptions.map((sub) => + deliveryRepo.create({ + subscriptionId: sub.id, + packetId: packet.id, + status: "pending", + attempts: 0, + nextAttemptAt: new Date(), + }), + ); + + // orIgnore skips deliveries that already exist for this packet/sub pair, + // so an evault-core retry of POST /ingest does not double-deliver. + const result = await deliveryRepo + .createQueryBuilder() + .insert() + .into(Delivery) + .values(rows) + .orIgnore() + .execute(); + + return { + packetId: packet.id, + deliveriesQueued: result.identifiers.filter(Boolean).length, + }; + } +} diff --git a/services/awareness-service/api/src/services/SubscriptionMatcher.ts b/services/awareness-service/api/src/services/SubscriptionMatcher.ts new file mode 100644 index 000000000..a05169114 --- /dev/null +++ b/services/awareness-service/api/src/services/SubscriptionMatcher.ts @@ -0,0 +1,35 @@ +import { AppDataSource } from "../database/data-source"; +import { Subscription } from "../database/entities/Subscription"; +import type { Packet } from "../database/entities/Packet"; + +/** + * Resolves which subscriptions should receive a given packet. A subscription + * matches when: + * - its consumer is approved and the subscription is active, and + * - its ontologyFilter is empty OR contains the packet's ontology, and + * - its evaultFilter is empty OR contains the packet's w3id / evaultPublicKey. + */ +export class SubscriptionMatcher { + async match(packet: Packet): Promise { + const evaultIds = [packet.w3id, packet.evaultPublicKey].filter( + (v): v is string => Boolean(v), + ); + + return AppDataSource.getRepository(Subscription) + .createQueryBuilder("s") + .innerJoin("consumers", "c", "c.id = s.consumerId") + .where("s.active = true") + .andWhere("c.status = :approved", { approved: "approved" }) + .andWhere( + "(cardinality(s.ontologyFilter) = 0 OR :ontology = ANY(s.ontologyFilter))", + { ontology: packet.ontology }, + ) + .andWhere( + evaultIds.length > 0 + ? "(cardinality(s.evaultFilter) = 0 OR s.evaultFilter && :evaultIds)" + : "cardinality(s.evaultFilter) = 0", + evaultIds.length > 0 ? { evaultIds } : {}, + ) + .getMany(); + } +} From d1c9a58f51157d8706b87aac6a660b6e48cd61a4 Mon Sep 17 00:00:00 2001 From: coodos Date: Tue, 5 May 2026 11:20:00 +0530 Subject: [PATCH 03/24] feat(awareness-service): polling query and subscription APIs Add consumer-facing endpoints, all authenticated by issued API keys: - GET /api/packets: poll awareness history by ontology, eVault and time range with opaque (receivedAt, id) cursor pagination. - /api/subscriptions: register/update/remove webhook subscriptions filtered by ontology and eVault. - /api/me: consumer profile, API key rotation, and recent delivery status. Includes ApiKeyService (SHA-256 hashed, plaintext shown once) and the consumerAuth middleware. --- .../api/src/controllers/ConsumerController.ts | 87 +++++++++++++ .../api/src/controllers/QueryController.ts | 98 +++++++++++++++ .../src/controllers/SubscriptionController.ts | 114 ++++++++++++++++++ services/awareness-service/api/src/index.ts | 6 + .../api/src/middleware/consumerAuth.ts | 39 ++++++ .../api/src/services/ApiKeyService.ts | 53 ++++++++ 6 files changed, 397 insertions(+) create mode 100644 services/awareness-service/api/src/controllers/ConsumerController.ts create mode 100644 services/awareness-service/api/src/controllers/QueryController.ts create mode 100644 services/awareness-service/api/src/controllers/SubscriptionController.ts create mode 100644 services/awareness-service/api/src/middleware/consumerAuth.ts create mode 100644 services/awareness-service/api/src/services/ApiKeyService.ts diff --git a/services/awareness-service/api/src/controllers/ConsumerController.ts b/services/awareness-service/api/src/controllers/ConsumerController.ts new file mode 100644 index 000000000..391d4e5c2 --- /dev/null +++ b/services/awareness-service/api/src/controllers/ConsumerController.ts @@ -0,0 +1,87 @@ +import { Router } from "express"; +import { AppDataSource } from "../database/data-source"; +import { ApiKey } from "../database/entities/ApiKey"; +import { Delivery } from "../database/entities/Delivery"; +import { Subscription } from "../database/entities/Subscription"; +import { consumerAuth } from "../middleware/consumerAuth"; +import { ApiKeyService } from "../services/ApiKeyService"; + +/** + * /api/me - consumer self-service: profile, API key rotation, and recent + * delivery status. All routes require a valid consumer API key. + */ +export function consumerRouter(): Router { + const router = Router(); + const apiKeyService = new ApiKeyService(); + router.use("/api/me", consumerAuth); + + router.get("/api/me", (req, res) => { + const c = req.consumer!; + res.json({ + id: c.id, + ename: c.ename, + name: c.name, + status: c.status, + webhookBaseUrl: c.webhookBaseUrl, + }); + }); + + router.get("/api/me/api-keys", async (req, res) => { + const keys = await AppDataSource.getRepository(ApiKey).find({ + where: { consumerId: req.consumer!.id }, + order: { createdAt: "DESC" }, + }); + res.json({ + apiKeys: keys.map((k) => ({ + id: k.id, + keyPrefix: k.keyPrefix, + revoked: k.revoked, + createdAt: k.createdAt, + lastUsedAt: k.lastUsedAt, + })), + }); + }); + + // Rotate / add a key. The plaintext is returned exactly once here. + router.post("/api/me/api-keys", async (req, res) => { + const { plaintext, apiKey } = await apiKeyService.issue( + req.consumer!.id, + ); + res.status(201).json({ + id: apiKey.id, + keyPrefix: apiKey.keyPrefix, + apiKey: plaintext, + }); + }); + + router.delete("/api/me/api-keys/:id", async (req, res) => { + const ok = await apiKeyService.revoke( + req.params.id, + req.consumer!.id, + ); + if (!ok) return res.status(404).json({ error: "not found" }); + res.json({ ok: true }); + }); + + // Recent delivery outcomes across this consumer's subscriptions. + router.get("/api/me/deliveries", async (req, res) => { + const limit = Math.min( + parseInt(String(req.query.limit ?? "50"), 10) || 50, + 200, + ); + const deliveries = await AppDataSource.getRepository(Delivery) + .createQueryBuilder("d") + .innerJoin( + Subscription, + "s", + "s.id = d.subscriptionId AND s.consumerId = :cid", + { cid: req.consumer!.id }, + ) + .orderBy("d.createdAt", "DESC") + .take(limit) + .getMany(); + res.json({ deliveries }); + }); + + return router; +} diff --git a/services/awareness-service/api/src/controllers/QueryController.ts b/services/awareness-service/api/src/controllers/QueryController.ts new file mode 100644 index 000000000..cd4c5e70b --- /dev/null +++ b/services/awareness-service/api/src/controllers/QueryController.ts @@ -0,0 +1,98 @@ +import { Router } from "express"; +import { Brackets } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Packet } from "../database/entities/Packet"; +import { consumerAuth } from "../middleware/consumerAuth"; +import { decodeCursor, encodeCursor } from "../utils/cursor"; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +/** + * GET /api/packets - polling query API. Approved consumers filter the awareness + * packet history by ontology, eVault and time range, paged with an opaque + * (receivedAt, id) cursor. + */ +export function queryRouter(): Router { + const router = Router(); + + router.get("/api/packets", consumerAuth, async (req, res) => { + const ontologies = String(req.query.ontology ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + const evault = + typeof req.query.evault === "string" ? req.query.evault : null; + const from = req.query.from + ? new Date(String(req.query.from)) + : null; + const to = req.query.to ? new Date(String(req.query.to)) : null; + + let limit = parseInt(String(req.query.limit ?? DEFAULT_LIMIT), 10); + if (Number.isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT; + limit = Math.min(limit, MAX_LIMIT); + + if ( + (from && Number.isNaN(from.getTime())) || + (to && Number.isNaN(to.getTime())) + ) { + return res + .status(400) + .json({ error: "from/to must be ISO timestamps" }); + } + + const qb = AppDataSource.getRepository(Packet) + .createQueryBuilder("p") + .orderBy("p.receivedAt", "ASC") + .addOrderBy("p.id", "ASC") + .take(limit + 1); + + if (ontologies.length > 0) { + qb.andWhere("p.ontology IN (:...ontologies)", { ontologies }); + } + if (evault) { + qb.andWhere( + "(p.w3id = :evault OR p.evaultPublicKey = :evault)", + { evault }, + ); + } + if (from) qb.andWhere("p.receivedAt >= :from", { from }); + if (to) qb.andWhere("p.receivedAt <= :to", { to }); + + if (typeof req.query.cursor === "string" && req.query.cursor) { + const cursor = decodeCursor(req.query.cursor); + if (!cursor) { + return res.status(400).json({ error: "invalid cursor" }); + } + qb.andWhere( + new Brackets((w) => { + w.where("p.receivedAt > :cReceived", { + cReceived: cursor.receivedAt, + }).orWhere( + "(p.receivedAt = :cReceived AND p.id > :cId)", + { cReceived: cursor.receivedAt, cId: cursor.id }, + ); + }), + ); + } + + const rows = await qb.getMany(); + const hasMore = rows.length > limit; + const packets = hasMore ? rows.slice(0, limit) : rows; + const last = packets[packets.length - 1]; + + return res.json({ + packets, + hasMore, + nextCursor: + hasMore && last + ? encodeCursor({ + receivedAt: last.receivedAt.toISOString(), + id: last.id, + }) + : null, + }); + }); + + return router; +} diff --git a/services/awareness-service/api/src/controllers/SubscriptionController.ts b/services/awareness-service/api/src/controllers/SubscriptionController.ts new file mode 100644 index 000000000..e4669e2e0 --- /dev/null +++ b/services/awareness-service/api/src/controllers/SubscriptionController.ts @@ -0,0 +1,114 @@ +import { Router } from "express"; +import { AppDataSource } from "../database/data-source"; +import { Subscription } from "../database/entities/Subscription"; +import { consumerAuth } from "../middleware/consumerAuth"; + +/** + * /api/subscriptions - lets an approved consumer dynamically register webhook + * subscriptions filtered by ontology and eVault. All routes are scoped to the + * authenticated consumer. + */ +export function subscriptionRouter(): Router { + const router = Router(); + router.use("/api/subscriptions", consumerAuth); + + const repo = () => AppDataSource.getRepository(Subscription); + + function normaliseTarget( + targetUrl: unknown, + webhookBaseUrl: string | null, + ): string | null { + if (typeof targetUrl === "string" && targetUrl.trim()) { + return targetUrl.trim(); + } + if (webhookBaseUrl) { + return new URL("/api/webhook", webhookBaseUrl).toString(); + } + return null; + } + + function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((v): v is string => typeof v === "string"); + } + + router.get("/api/subscriptions", async (req, res) => { + const subs = await repo().find({ + where: { consumerId: req.consumer!.id }, + order: { createdAt: "DESC" }, + }); + res.json({ subscriptions: subs }); + }); + + router.post("/api/subscriptions", async (req, res) => { + const targetUrl = normaliseTarget( + req.body?.targetUrl, + req.consumer!.webhookBaseUrl, + ); + if (!targetUrl) { + return res.status(400).json({ + error: "targetUrl is required (no webhookBaseUrl on consumer)", + }); + } + + const sub = repo().create({ + consumerId: req.consumer!.id, + targetUrl, + ontologyFilter: toStringArray(req.body?.ontologyFilter), + evaultFilter: toStringArray(req.body?.evaultFilter), + secret: + typeof req.body?.secret === "string" + ? req.body.secret + : null, + isCatchAll: false, + active: true, + }); + await repo().save(sub); + res.status(201).json({ subscription: sub }); + }); + + router.patch("/api/subscriptions/:id", async (req, res) => { + const sub = await repo().findOne({ + where: { id: req.params.id, consumerId: req.consumer!.id }, + }); + if (!sub) return res.status(404).json({ error: "not found" }); + + if (req.body?.targetUrl !== undefined) { + const t = normaliseTarget( + req.body.targetUrl, + req.consumer!.webhookBaseUrl, + ); + if (t) sub.targetUrl = t; + } + if (req.body?.ontologyFilter !== undefined) { + sub.ontologyFilter = toStringArray(req.body.ontologyFilter); + } + if (req.body?.evaultFilter !== undefined) { + sub.evaultFilter = toStringArray(req.body.evaultFilter); + } + if (typeof req.body?.active === "boolean") { + sub.active = req.body.active; + } + if (req.body?.secret !== undefined) { + sub.secret = + typeof req.body.secret === "string" + ? req.body.secret + : null; + } + await repo().save(sub); + res.json({ subscription: sub }); + }); + + router.delete("/api/subscriptions/:id", async (req, res) => { + const result = await repo().update( + { id: req.params.id, consumerId: req.consumer!.id }, + { active: false }, + ); + if (!result.affected) { + return res.status(404).json({ error: "not found" }); + } + res.json({ ok: true }); + }); + + return router; +} diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts index f65885646..33d52b715 100644 --- a/services/awareness-service/api/src/index.ts +++ b/services/awareness-service/api/src/index.ts @@ -2,7 +2,10 @@ import "reflect-metadata"; import cors from "cors"; import express from "express"; import { config } from "./config"; +import { consumerRouter } from "./controllers/ConsumerController"; import { ingestRouter } from "./controllers/IngestController"; +import { queryRouter } from "./controllers/QueryController"; +import { subscriptionRouter } from "./controllers/SubscriptionController"; import { AppDataSource } from "./database/data-source"; import { DeliveryEngine } from "./services/DeliveryEngine"; @@ -19,6 +22,9 @@ async function start(): Promise { }); app.use(ingestRouter()); + app.use(queryRouter()); + app.use(subscriptionRouter()); + app.use(consumerRouter()); const deliveryEngine = new DeliveryEngine(); deliveryEngine.start(); diff --git a/services/awareness-service/api/src/middleware/consumerAuth.ts b/services/awareness-service/api/src/middleware/consumerAuth.ts new file mode 100644 index 000000000..81e550aea --- /dev/null +++ b/services/awareness-service/api/src/middleware/consumerAuth.ts @@ -0,0 +1,39 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppDataSource } from "../database/data-source"; +import { Consumer } from "../database/entities/Consumer"; +import { ApiKeyService } from "../services/ApiKeyService"; + +const apiKeyService = new ApiKeyService(); + +/** + * Authenticates a consumer by `Authorization: Bearer `. Rejects unless + * the key is valid and its consumer is approved. Sets `req.consumer`. + */ +export async function consumerAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const header = req.header("authorization"); + if (!header?.startsWith("Bearer ")) { + res.status(401).json({ error: "missing API key" }); + return; + } + + const apiKey = await apiKeyService.verify(header.slice(7).trim()); + if (!apiKey) { + res.status(401).json({ error: "invalid API key" }); + return; + } + + const consumer = await AppDataSource.getRepository(Consumer).findOne({ + where: { id: apiKey.consumerId }, + }); + if (!consumer || consumer.status !== "approved") { + res.status(403).json({ error: "consumer not approved" }); + return; + } + + req.consumer = consumer; + next(); +} diff --git a/services/awareness-service/api/src/services/ApiKeyService.ts b/services/awareness-service/api/src/services/ApiKeyService.ts new file mode 100644 index 000000000..af7cab004 --- /dev/null +++ b/services/awareness-service/api/src/services/ApiKeyService.ts @@ -0,0 +1,53 @@ +import crypto from "crypto"; +import { AppDataSource } from "../database/data-source"; +import { ApiKey } from "../database/entities/ApiKey"; + +const KEY_PREFIX = "aaas_"; + +/** Issues and verifies long-lived consumer API keys. Only hashes are stored. */ +export class ApiKeyService { + private hash(plaintext: string): string { + return crypto.createHash("sha256").update(plaintext).digest("hex"); + } + + /** + * Creates a new key for a consumer. Returns the plaintext exactly once - + * it is never recoverable afterwards. + */ + async issue( + consumerId: string, + ): Promise<{ apiKey: ApiKey; plaintext: string }> { + const plaintext = KEY_PREFIX + crypto.randomBytes(24).toString("hex"); + const repo = AppDataSource.getRepository(ApiKey); + const apiKey = repo.create({ + consumerId, + keyHash: this.hash(plaintext), + keyPrefix: plaintext.slice(0, 12), + revoked: false, + }); + await repo.save(apiKey); + return { apiKey, plaintext }; + } + + /** Resolves a plaintext key to its (non-revoked) ApiKey row, or null. */ + async verify(plaintext: string): Promise { + const apiKey = await AppDataSource.getRepository(ApiKey).findOne({ + where: { keyHash: this.hash(plaintext), revoked: false }, + }); + if (apiKey) { + // best-effort last-used tracking + void AppDataSource.getRepository(ApiKey).update(apiKey.id, { + lastUsedAt: new Date(), + }); + } + return apiKey; + } + + async revoke(id: string, consumerId: string): Promise { + const result = await AppDataSource.getRepository(ApiKey).update( + { id, consumerId }, + { revoked: true }, + ); + return (result.affected ?? 0) > 0; + } +} From e45f34d9ec45a737564beb23f1567173f2d5e0ea Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 6 May 2026 16:05:00 +0530 Subject: [PATCH 04/24] feat(awareness-service): W3DS portal auth, applications and admin API Add the portal-facing surface: - /api/auth: W3DS login - offer a w3ds://auth deeplink, verify the wallet signature against the registry, exchange it for a session JWT. - /api/applications: a logged-in platform applies for access and tracks status. - /api/admin: whitelisted admins (AAAS_ADMIN_ENAMES) approve/reject applications and inspect/replay dead-lettered deliveries. consumerAuth now accepts either a machine API key or a portal session JWT so a freshly approved consumer can issue its first key from the dashboard. --- .../api/src/controllers/AdminController.ts | 112 ++++++++++++++++++ .../src/controllers/ApplicationController.ts | 82 +++++++++++++ .../api/src/controllers/AuthController.ts | 43 +++++++ services/awareness-service/api/src/index.ts | 6 + .../api/src/middleware/consumerAuth.ts | 42 +++++-- .../api/src/middleware/portalAuth.ts | 42 +++++++ .../api/src/services/W3dsAuthService.ts | 104 ++++++++++++++++ 7 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 services/awareness-service/api/src/controllers/AdminController.ts create mode 100644 services/awareness-service/api/src/controllers/ApplicationController.ts create mode 100644 services/awareness-service/api/src/controllers/AuthController.ts create mode 100644 services/awareness-service/api/src/middleware/portalAuth.ts create mode 100644 services/awareness-service/api/src/services/W3dsAuthService.ts diff --git a/services/awareness-service/api/src/controllers/AdminController.ts b/services/awareness-service/api/src/controllers/AdminController.ts new file mode 100644 index 000000000..ac2017b4b --- /dev/null +++ b/services/awareness-service/api/src/controllers/AdminController.ts @@ -0,0 +1,112 @@ +import { Router } from "express"; +import { AppDataSource } from "../database/data-source"; +import { AccessApplication } from "../database/entities/AccessApplication"; +import { Consumer } from "../database/entities/Consumer"; +import { DeadLetter } from "../database/entities/DeadLetter"; +import { Delivery } from "../database/entities/Delivery"; +import { adminAuth } from "../middleware/portalAuth"; + +/** + * /api/admin - whitelisted admins review access applications and inspect / + * replay dead-lettered webhook deliveries. + */ +export function adminRouter(): Router { + const router = Router(); + router.use("/api/admin", adminAuth); + + // Pending (or all) access applications with their consumer details. + router.get("/api/admin/applications", async (req, res) => { + const status = (req.query.status as string) ?? "pending"; + const apps = await AppDataSource.getRepository(AccessApplication) + .createQueryBuilder("a") + .innerJoinAndMapOne( + "a.consumer", + Consumer, + "c", + "c.id = a.consumerId", + ) + .where(status === "all" ? "1=1" : "a.status = :status", { + status, + }) + .orderBy("a.createdAt", "DESC") + .getMany(); + res.json({ applications: apps }); + }); + + router.post("/api/admin/applications/:id/approve", async (req, res) => { + const appRepo = AppDataSource.getRepository(AccessApplication); + const application = await appRepo.findOne({ + where: { id: req.params.id }, + }); + if (!application) return res.status(404).json({ error: "not found" }); + + application.status = "approved"; + application.reviewedByEname = req.ename!; + application.reviewNote = req.body?.note ?? null; + application.reviewedAt = new Date(); + await appRepo.save(application); + + await AppDataSource.getRepository(Consumer).update( + application.consumerId, + { status: "approved", approvedAt: new Date() }, + ); + res.json({ ok: true, application }); + }); + + router.post("/api/admin/applications/:id/reject", async (req, res) => { + const appRepo = AppDataSource.getRepository(AccessApplication); + const application = await appRepo.findOne({ + where: { id: req.params.id }, + }); + if (!application) return res.status(404).json({ error: "not found" }); + + application.status = "rejected"; + application.reviewedByEname = req.ename!; + application.reviewNote = req.body?.note ?? null; + application.reviewedAt = new Date(); + await appRepo.save(application); + + await AppDataSource.getRepository(Consumer).update( + application.consumerId, + { status: "rejected" }, + ); + res.json({ ok: true, application }); + }); + + router.get("/api/admin/dead-letters", async (req, res) => { + const includeResolved = req.query.resolved === "true"; + const deadLetters = await AppDataSource.getRepository( + DeadLetter, + ).find({ + where: includeResolved ? {} : { resolved: false }, + order: { createdAt: "DESC" }, + take: 200, + }); + res.json({ deadLetters }); + }); + + // Replay re-queues the original delivery and resolves the dead letter. + router.post("/api/admin/dead-letters/:id/replay", async (req, res) => { + const dlRepo = AppDataSource.getRepository(DeadLetter); + const deadLetter = await dlRepo.findOne({ + where: { id: req.params.id }, + }); + if (!deadLetter) return res.status(404).json({ error: "not found" }); + + await AppDataSource.getRepository(Delivery).update( + deadLetter.deliveryId, + { + status: "pending", + attempts: 0, + nextAttemptAt: new Date(), + lastError: null, + lastResponseStatus: null, + }, + ); + deadLetter.resolved = true; + await dlRepo.save(deadLetter); + res.json({ ok: true }); + }); + + return router; +} diff --git a/services/awareness-service/api/src/controllers/ApplicationController.ts b/services/awareness-service/api/src/controllers/ApplicationController.ts new file mode 100644 index 000000000..0ba7b49e8 --- /dev/null +++ b/services/awareness-service/api/src/controllers/ApplicationController.ts @@ -0,0 +1,82 @@ +import { Router } from "express"; +import { AppDataSource } from "../database/data-source"; +import { AccessApplication } from "../database/entities/AccessApplication"; +import { Consumer } from "../database/entities/Consumer"; +import { portalAuth } from "../middleware/portalAuth"; + +/** + * /api/applications - a logged-in platform applies for awareness access and + * checks the status of its own application. + */ +export function applicationRouter(): Router { + const router = Router(); + router.use("/api/applications", portalAuth); + + router.get("/api/applications/me", async (req, res) => { + const consumer = await AppDataSource.getRepository(Consumer).findOne({ + where: { ename: req.ename! }, + }); + if (!consumer) return res.json({ consumer: null, application: null }); + + const application = await AppDataSource.getRepository( + AccessApplication, + ).findOne({ + where: { consumerId: consumer.id }, + order: { createdAt: "DESC" }, + }); + res.json({ consumer, application }); + }); + + router.post("/api/applications", async (req, res) => { + const consumerRepo = AppDataSource.getRepository(Consumer); + const appRepo = AppDataSource.getRepository(AccessApplication); + + let consumer = await consumerRepo.findOne({ + where: { ename: req.ename! }, + }); + + if (consumer && consumer.status === "approved") { + return res + .status(409) + .json({ error: "consumer is already approved" }); + } + + const { name, contactEmail, justification, webhookBaseUrl } = + req.body ?? {}; + const requestedOntologies = Array.isArray( + req.body?.requestedOntologies, + ) + ? req.body.requestedOntologies.filter( + (o: unknown): o is string => typeof o === "string", + ) + : []; + + if (!consumer) { + consumer = consumerRepo.create({ + ename: req.ename!, + status: "pending", + }); + } + consumer.name = name ?? consumer.name; + consumer.contactEmail = contactEmail ?? consumer.contactEmail; + consumer.webhookBaseUrl = webhookBaseUrl ?? consumer.webhookBaseUrl; + consumer.status = "pending"; + await consumerRepo.save(consumer); + + // Reuse an existing pending application rather than stacking duplicates. + let application = await appRepo.findOne({ + where: { consumerId: consumer.id, status: "pending" }, + }); + if (!application) { + application = appRepo.create({ consumerId: consumer.id }); + } + application.justification = justification ?? null; + application.requestedOntologies = requestedOntologies; + application.status = "pending"; + await appRepo.save(application); + + res.status(201).json({ consumer, application }); + }); + + return router; +} diff --git a/services/awareness-service/api/src/controllers/AuthController.ts b/services/awareness-service/api/src/controllers/AuthController.ts new file mode 100644 index 000000000..9e7bccd2e --- /dev/null +++ b/services/awareness-service/api/src/controllers/AuthController.ts @@ -0,0 +1,43 @@ +import { Router } from "express"; +import { w3dsAuthService } from "../services/W3dsAuthService"; + +/** + * /api/auth - W3DS login for the portal. The portal requests an offer, the eID + * wallet posts a signature to the callback, and the portal polls for the + * resulting session JWT. + */ +export function authRouter(): Router { + const router = Router(); + + // Portal: start a login and get a w3ds://auth offer. + router.post("/api/auth/offer", (_req, res) => { + res.json(w3dsAuthService.createOffer()); + }); + + // Wallet callback: verify the signed session id. + router.post("/api/auth", async (req, res) => { + const ename = req.body?.w3id ?? req.body?.ename; + const { session, signature } = req.body ?? {}; + if (!ename || !session || !signature) { + return res + .status(400) + .json({ error: "w3id, session and signature are required" }); + } + const result = await w3dsAuthService.completeLogin( + ename, + session, + signature, + ); + if (!result.ok) { + return res.status(401).json({ error: result.error }); + } + res.json({ ok: true }); + }); + + // Portal: poll until the wallet has signed in. + router.get("/api/auth/session/:session", (req, res) => { + res.json(w3dsAuthService.pollSession(req.params.session)); + }); + + return router; +} diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts index 33d52b715..406b333df 100644 --- a/services/awareness-service/api/src/index.ts +++ b/services/awareness-service/api/src/index.ts @@ -2,6 +2,9 @@ import "reflect-metadata"; import cors from "cors"; import express from "express"; import { config } from "./config"; +import { adminRouter } from "./controllers/AdminController"; +import { applicationRouter } from "./controllers/ApplicationController"; +import { authRouter } from "./controllers/AuthController"; import { consumerRouter } from "./controllers/ConsumerController"; import { ingestRouter } from "./controllers/IngestController"; import { queryRouter } from "./controllers/QueryController"; @@ -25,6 +28,9 @@ async function start(): Promise { app.use(queryRouter()); app.use(subscriptionRouter()); app.use(consumerRouter()); + app.use(authRouter()); + app.use(applicationRouter()); + app.use(adminRouter()); const deliveryEngine = new DeliveryEngine(); deliveryEngine.start(); diff --git a/services/awareness-service/api/src/middleware/consumerAuth.ts b/services/awareness-service/api/src/middleware/consumerAuth.ts index 81e550aea..f30ecb89b 100644 --- a/services/awareness-service/api/src/middleware/consumerAuth.ts +++ b/services/awareness-service/api/src/middleware/consumerAuth.ts @@ -2,12 +2,19 @@ import type { NextFunction, Request, Response } from "express"; import { AppDataSource } from "../database/data-source"; import { Consumer } from "../database/entities/Consumer"; import { ApiKeyService } from "../services/ApiKeyService"; +import { w3dsAuthService } from "../services/W3dsAuthService"; const apiKeyService = new ApiKeyService(); /** - * Authenticates a consumer by `Authorization: Bearer `. Rejects unless - * the key is valid and its consumer is approved. Sets `req.consumer`. + * Authenticates a consumer for the management + query API. Accepts either: + * - a machine API key (`aaas_...`) - used for polling and automation, or + * - a W3DS portal session JWT - used by the dashboard, resolved to the + * consumer that owns the logged-in eName. + * + * This dual mode lets a freshly approved consumer issue its first API key from + * the portal (it has no key yet). Either way the consumer must be approved and + * `req.consumer` is set. */ export async function consumerAuth( req: Request, @@ -16,19 +23,34 @@ export async function consumerAuth( ): Promise { const header = req.header("authorization"); if (!header?.startsWith("Bearer ")) { - res.status(401).json({ error: "missing API key" }); + res.status(401).json({ error: "authentication required" }); return; } + const token = header.slice(7).trim(); + const consumerRepo = AppDataSource.getRepository(Consumer); - const apiKey = await apiKeyService.verify(header.slice(7).trim()); - if (!apiKey) { - res.status(401).json({ error: "invalid API key" }); - return; + let consumer: Consumer | null = null; + + if (token.startsWith("aaas_")) { + const apiKey = await apiKeyService.verify(token); + if (!apiKey) { + res.status(401).json({ error: "invalid API key" }); + return; + } + consumer = await consumerRepo.findOne({ + where: { id: apiKey.consumerId }, + }); + } else { + const decoded = w3dsAuthService.verifyToken(token); + if (!decoded) { + res.status(401).json({ error: "invalid session token" }); + return; + } + consumer = await consumerRepo.findOne({ + where: { ename: decoded.ename }, + }); } - const consumer = await AppDataSource.getRepository(Consumer).findOne({ - where: { id: apiKey.consumerId }, - }); if (!consumer || consumer.status !== "approved") { res.status(403).json({ error: "consumer not approved" }); return; diff --git a/services/awareness-service/api/src/middleware/portalAuth.ts b/services/awareness-service/api/src/middleware/portalAuth.ts new file mode 100644 index 000000000..50a28cfe9 --- /dev/null +++ b/services/awareness-service/api/src/middleware/portalAuth.ts @@ -0,0 +1,42 @@ +import type { NextFunction, Request, Response } from "express"; +import { config } from "../config"; +import { w3dsAuthService } from "../services/W3dsAuthService"; + +/** + * Authenticates a portal user by their W3DS session JWT (Authorization Bearer). + * Sets `req.ename` and `req.isAdmin`. + */ +export function portalAuth( + req: Request, + res: Response, + next: NextFunction, +): void { + const header = req.header("authorization"); + if (!header?.startsWith("Bearer ")) { + res.status(401).json({ error: "authentication required" }); + return; + } + const decoded = w3dsAuthService.verifyToken(header.slice(7).trim()); + if (!decoded) { + res.status(401).json({ error: "invalid session token" }); + return; + } + req.ename = decoded.ename; + req.isAdmin = config.adminEnames.includes(decoded.ename); + next(); +} + +/** Requires the authenticated portal user to be an admin (AAAS_ADMIN_ENAMES). */ +export function adminAuth( + req: Request, + res: Response, + next: NextFunction, +): void { + portalAuth(req, res, () => { + if (!req.isAdmin) { + res.status(403).json({ error: "admin access required" }); + return; + } + next(); + }); +} diff --git a/services/awareness-service/api/src/services/W3dsAuthService.ts b/services/awareness-service/api/src/services/W3dsAuthService.ts new file mode 100644 index 000000000..045ca0dab --- /dev/null +++ b/services/awareness-service/api/src/services/W3dsAuthService.ts @@ -0,0 +1,104 @@ +import { randomUUID } from "crypto"; +import jwt from "jsonwebtoken"; +import { verifySignature } from "signature-validator"; +import { config } from "../config"; + +interface PendingSession { + createdAt: number; + ename?: string; + status: "pending" | "authenticated"; +} + +const SESSION_TTL_MS = 10 * 60 * 1000; + +/** + * Handles W3DS login for the portal. A session id is signed by the user's eID + * wallet; the signature is verified against the registry and exchanged for a + * portal session JWT. Pending sessions are held in memory with a short TTL. + */ +export class W3dsAuthService { + private sessions = new Map(); + + /** Builds a w3ds://auth offer the portal renders as a QR / deeplink. */ + createOffer(): { uri: string; session: string } { + this.gc(); + const session = randomUUID(); + this.sessions.set(session, { + createdAt: Date.now(), + status: "pending", + }); + const redirect = new URL("/api/auth", config.publicUrl).toString(); + const uri = `w3ds://auth?redirect=${redirect}&session=${session}&platform=awareness-service`; + return { uri, session }; + } + + /** Callback target: verifies the wallet signature over the session id. */ + async completeLogin( + ename: string, + session: string, + signature: string, + ): Promise<{ ok: boolean; error?: string }> { + const pending = this.sessions.get(session); + if (!pending) return { ok: false, error: "unknown or expired session" }; + + const result = await verifySignature({ + eName: ename, + signature, + payload: session, + registryBaseUrl: config.registryUrl, + }); + if (!result.valid) { + return { ok: false, error: result.error ?? "invalid signature" }; + } + + pending.ename = ename; + pending.status = "authenticated"; + return { ok: true }; + } + + /** + * Polled by the portal. Once authenticated, returns a session JWT and + * consumes the pending session. + */ + pollSession( + session: string, + ): { status: "pending" } | { status: "authenticated"; token: string } { + const pending = this.sessions.get(session); + if (!pending) { + return { status: "pending" }; + } + if (pending.status === "authenticated" && pending.ename) { + this.sessions.delete(session); + return { + status: "authenticated", + token: this.issueToken(pending.ename), + }; + } + return { status: "pending" }; + } + + issueToken(ename: string): string { + return jwt.sign({ ename }, config.jwtSecret, { expiresIn: "7d" }); + } + + verifyToken(token: string): { ename: string } | null { + try { + const decoded = jwt.verify(token, config.jwtSecret) as { + ename?: string; + }; + return decoded.ename ? { ename: decoded.ename } : null; + } catch { + return null; + } + } + + private gc(): void { + const now = Date.now(); + for (const [id, s] of this.sessions) { + if (now - s.createdAt > SESSION_TTL_MS) this.sessions.delete(id); + } + } +} + +/** Shared singleton so the auth offer and callback share session state. */ +export const w3dsAuthService = new W3dsAuthService(); From 6452511e230152057951791380feacfd7bc598e8 Mon Sep 17 00:00:00 2001 From: coodos Date: Thu, 7 May 2026 13:40:00 +0530 Subject: [PATCH 05/24] feat(awareness-service): Neo4j backfill and catch-all seeding Add the one-time migration path: - backfill-neo4j.ts reads MetaEnvelopes directly from evault-core's Neo4j (same node), reconstructs each packet's data payload and upserts into the packets table. Seeds history only - no deliveries are queued. - SeedService ensures every platform in the registry has an approved consumer and a catch-all subscription, so existing webhook receivers keep working unchanged. Runs on every API launch and via the seed:catchall script. --- services/awareness-service/api/src/index.ts | 5 + .../api/src/scripts/backfill-neo4j.ts | 115 ++++++++++++++++++ .../api/src/scripts/seed-catchall.ts | 22 ++++ .../api/src/services/SeedService.ts | 89 ++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 services/awareness-service/api/src/scripts/backfill-neo4j.ts create mode 100644 services/awareness-service/api/src/scripts/seed-catchall.ts create mode 100644 services/awareness-service/api/src/services/SeedService.ts diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts index 406b333df..884d5216b 100644 --- a/services/awareness-service/api/src/index.ts +++ b/services/awareness-service/api/src/index.ts @@ -11,6 +11,7 @@ import { queryRouter } from "./controllers/QueryController"; import { subscriptionRouter } from "./controllers/SubscriptionController"; import { AppDataSource } from "./database/data-source"; import { DeliveryEngine } from "./services/DeliveryEngine"; +import { SeedService } from "./services/SeedService"; async function start(): Promise { await AppDataSource.initialize(); @@ -32,6 +33,10 @@ async function start(): Promise { app.use(applicationRouter()); app.use(adminRouter()); + // Backward compat: keep every currently-registered platform receiving + // everything, the same way evault-core's old fanout did. + await new SeedService().seedCatchAll(); + const deliveryEngine = new DeliveryEngine(); deliveryEngine.start(); diff --git a/services/awareness-service/api/src/scripts/backfill-neo4j.ts b/services/awareness-service/api/src/scripts/backfill-neo4j.ts new file mode 100644 index 000000000..07ed18fe6 --- /dev/null +++ b/services/awareness-service/api/src/scripts/backfill-neo4j.ts @@ -0,0 +1,115 @@ +import "reflect-metadata"; +import neo4j from "neo4j-driver"; +import { AppDataSource } from "../database/data-source"; +import { Packet } from "../database/entities/Packet"; + +/** + * One-time backfill. AaaS runs on the same physical node as evault-core's Neo4j, + * so this script reads MetaEnvelopes straight from the graph and seeds the + * `packets` table. It is idempotent (upsert keyed on packet id) and re-runnable. + * + * It seeds the packet store ONLY - it deliberately does not create deliveries, + * which would spam subscribers with the entire history on go-live. + */ + +const BATCH = 500; + +/** Mirrors evault-core's deserializeValue for backfilled envelope values. */ +function deserialize(value: unknown, type: string): unknown { + if (type === "object" && typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + if (type === "array" && typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +async function main(): Promise { + const uri = process.env.AWARENESS_NEO4J_URI ?? "bolt://localhost:7687"; + const user = process.env.AWARENESS_NEO4J_USER ?? "neo4j"; + const password = process.env.AWARENESS_NEO4J_PASSWORD ?? "neo4j"; + const evaultPublicKey = process.env.EVAULT_PUBLIC_KEY ?? null; + + const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); + await AppDataSource.initialize(); + const packetRepo = AppDataSource.getRepository(Packet); + const backfillTs = new Date(); + + let skip = 0; + let total = 0; + + try { + for (;;) { + const session = driver.session(); + let rows: any[]; + try { + const result = await session.run( + `MATCH (m:MetaEnvelope)-[:LINKS_TO]->(e:Envelope) + RETURN m.id AS id, m.ontology AS ontology, m.eName AS eName, + collect({ontology: e.ontology, value: e.value, valueType: e.valueType}) AS envelopes + SKIP $skip LIMIT $batch`, + { skip: neo4j.int(skip), batch: neo4j.int(BATCH) }, + ); + rows = result.records.map((r) => ({ + id: r.get("id"), + ontology: r.get("ontology"), + eName: r.get("eName"), + envelopes: r.get("envelopes"), + })); + } finally { + await session.close(); + } + + if (rows.length === 0) break; + + const packets = rows + .filter((row) => row.id && row.ontology) + .map((row) => { + const data: Record = {}; + for (const env of row.envelopes ?? []) { + if (env?.ontology) { + data[env.ontology] = deserialize( + env.value, + env.valueType, + ); + } + } + return packetRepo.create({ + id: row.id, + ontology: row.ontology, + w3id: row.eName ?? null, + evaultPublicKey, + data, + operation: "create" as const, + receivedAt: backfillTs, + }); + }); + + if (packets.length > 0) { + await packetRepo.upsert(packets, ["id"]); + total += packets.length; + } + console.log(`[backfill] processed ${total} packets...`); + skip += BATCH; + } + + console.log(`[backfill] complete: ${total} packets seeded`); + } finally { + await driver.close(); + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error("[backfill] failed:", err); + process.exit(1); +}); diff --git a/services/awareness-service/api/src/scripts/seed-catchall.ts b/services/awareness-service/api/src/scripts/seed-catchall.ts new file mode 100644 index 000000000..58c8d2651 --- /dev/null +++ b/services/awareness-service/api/src/scripts/seed-catchall.ts @@ -0,0 +1,22 @@ +import "reflect-metadata"; +import { AppDataSource } from "../database/data-source"; +import { SeedService } from "../services/SeedService"; + +/** + * Standalone runner for catch-all subscription seeding. The same logic also + * runs automatically on every API launch; this script is for manual re-runs + * (e.g. after new platforms register with the registry). + */ +async function main(): Promise { + await AppDataSource.initialize(); + const result = await new SeedService().seedCatchAll(); + console.log( + `[seed-catchall] complete: ${result.seeded} new / ${result.total} platforms`, + ); + await AppDataSource.destroy(); +} + +main().catch((err) => { + console.error("[seed-catchall] failed:", err); + process.exit(1); +}); diff --git a/services/awareness-service/api/src/services/SeedService.ts b/services/awareness-service/api/src/services/SeedService.ts new file mode 100644 index 000000000..ca3032162 --- /dev/null +++ b/services/awareness-service/api/src/services/SeedService.ts @@ -0,0 +1,89 @@ +import axios from "axios"; +import { AppDataSource } from "../database/data-source"; +import { Consumer } from "../database/entities/Consumer"; +import { Subscription } from "../database/entities/Subscription"; +import { config } from "../config"; + +/** + * Backward-compat seeding. Before AaaS, evault-core fanned out every webhook to + * every registered platform. To preserve that behaviour, on launch we ensure + * each platform currently in the registry has an approved consumer and a + * catch-all subscription (empty filters) pointing at `/api/webhook`. + * + * Idempotent: existing catch-all subscriptions are left untouched. + */ +export class SeedService { + async seedCatchAll(): Promise<{ seeded: number; total: number }> { + if (!config.registryUrl) { + console.warn("[seed] PUBLIC_REGISTRY_URL not set, skipping"); + return { seeded: 0, total: 0 }; + } + + let platforms: string[] = []; + try { + const response = await axios.get( + new URL("/platforms", config.registryUrl).toString(), + { timeout: 10000 }, + ); + platforms = Array.isArray(response.data) ? response.data : []; + } catch (err) { + console.error("[seed] failed to fetch registry platforms:", err); + return { seeded: 0, total: 0 }; + } + + const consumerRepo = AppDataSource.getRepository(Consumer); + const subRepo = AppDataSource.getRepository(Subscription); + let seeded = 0; + + for (const platformUrl of platforms) { + let host: string; + let targetUrl: string; + try { + host = new URL(platformUrl).host; + targetUrl = new URL("/api/webhook", platformUrl).toString(); + } catch { + console.warn(`[seed] skipping invalid platform: ${platformUrl}`); + continue; + } + + const ename = `catchall:${host}`; + let consumer = await consumerRepo.findOne({ where: { ename } }); + if (!consumer) { + consumer = consumerRepo.create({ + ename, + name: host, + status: "approved", + webhookBaseUrl: platformUrl, + approvedAt: new Date(), + }); + await consumerRepo.save(consumer); + } + + const existing = await subRepo.findOne({ + where: { + consumerId: consumer.id, + isCatchAll: true, + targetUrl, + }, + }); + if (!existing) { + await subRepo.save( + subRepo.create({ + consumerId: consumer.id, + targetUrl, + ontologyFilter: [], + evaultFilter: [], + isCatchAll: true, + active: true, + }), + ); + seeded += 1; + } + } + + console.log( + `[seed] catch-all seeding done: ${seeded} new of ${platforms.length} platforms`, + ); + return { seeded, total: platforms.length }; + } +} From 31d7e455e34ef049a3e5e5489f4d151d68a5a372 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 8 May 2026 09:50:00 +0530 Subject: [PATCH 06/24] feat(evault-core): replace webhook fanout with AaaS ingest evault-core no longer queries the registry and fans out webhooks to every platform. getActivePlatforms and deliverWebhooks are removed; a single notifyAwareness POST forwards each awareness packet to AWARENESS_SERVICE_URL/ ingest, authenticated by a shared secret. All five mutation call sites (create, update, bulk-create, binding document create/sign) are updated. The requesting platform is passed through to AaaS so it can skip delivering a packet back to its origin, preserving the ping-pong guard the old fanout had. --- .../src/core/protocol/graphql-server.ts | 208 ++++++------------ .../api/src/services/IngestService.ts | 23 +- services/awareness-service/api/src/types.ts | 5 + 3 files changed, 96 insertions(+), 140 deletions(-) diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 06067a027..b449d6d89 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -62,44 +62,29 @@ export class GraphQLServer { } /** - * Fetches the list of active platforms from the registry - * @returns Promise - Array of platform URLs + * Forwards an awareness packet to Awareness as a Service (AaaS). + * + * AaaS has replaced eVault's built-in webhook fanout: instead of querying + * the registry and POSTing to every platform here, we make a single POST + * to AaaS, which owns subscription matching, retry/dead-letter delivery and + * the catch-all fanout that preserves the previous behaviour. + * + * @param webhookPayload - The awareness packet { id, w3id, evaultPublicKey, + * data, schemaId } + * @param requestingPlatform - The platform that triggered the change, if + * known. AaaS uses it to skip delivering the packet + * back to its origin (prevents webhook ping-pong). */ - private async getActivePlatforms(): Promise { - try { - if (!process.env.PUBLIC_REGISTRY_URL) { - return []; - } - - const response = await axios.get( - new URL( - "/platforms", - process.env.PUBLIC_REGISTRY_URL, - ).toString(), - ); - return response.data; - } catch (error) { - return []; - } - } - - /** - * Delivers webhooks to all platforms except the requesting one - * @param requestingPlatform - The platform that made the request (if any) - * @param webhookPayload - The payload to send to webhooks - */ - private async deliverWebhooks( - requestingPlatform: string | null, + private async notifyAwareness( webhookPayload: any, + requestingPlatform: string | null = null, ): Promise { - // One log line per dispatch — the same payload goes to every - // target platform, so we log the body once here instead of per - // target. This is the source of truth for "what eVault claims it - // sent"; correlate against receiver logs to find divergence. + // One log line per dispatch — this remains the source of truth for + // "what eVault claims it sent"; correlate against AaaS ingest logs. try { const payloadJson = JSON.stringify(webhookPayload); console.log( - `[webhook] id=${webhookPayload?.id} schemaId=${webhookPayload?.schemaId} w3id=${webhookPayload?.w3id} from=${requestingPlatform ?? ""} payload=${payloadJson}`, + `[webhook] id=${webhookPayload?.id} schemaId=${webhookPayload?.schemaId} w3id=${webhookPayload?.w3id} payload=${payloadJson}`, ); } catch { console.log( @@ -107,55 +92,29 @@ export class GraphQLServer { ); } - try { - const activePlatforms = await this.getActivePlatforms(); - - // Filter out the requesting platform - const platformsToNotify = activePlatforms.filter((platformUrl) => { - if (!requestingPlatform) return true; - - try { - // Normalize URLs for comparison - const normalizedPlatformUrl = new URL( - platformUrl, - ).toString(); - const normalizedRequestingPlatform = new URL( - requestingPlatform, - ).toString(); - - return ( - normalizedPlatformUrl !== normalizedRequestingPlatform - ); - } catch (error) { - // If requestingPlatform is not a valid URL, don't filter it out - // (treat it as a different platform identifier) - return true; - } - }); + if (!process.env.AWARENESS_SERVICE_URL) { + console.log("[webhook] AWARENESS_SERVICE_URL not set, skipping"); + return; + } - // Send webhooks to all other platforms - const webhookPromises = platformsToNotify.map( - async (platformUrl) => { - try { - const webhookUrl = new URL( - "/api/webhook", - platformUrl, - ).toString(); - await axios.post(webhookUrl, webhookPayload, { - headers: { - "Content-Type": "application/json", - }, - timeout: 5000, // 5 second timeout - }); - } catch (error) { - console.log(`Webhook delivery failed to ${platformUrl}`); - } + try { + await axios.post( + new URL( + "/ingest", + process.env.AWARENESS_SERVICE_URL, + ).toString(), + { ...webhookPayload, requestingPlatform }, + { + headers: { + "Content-Type": "application/json", + "x-ingest-secret": + process.env.AWARENESS_INGEST_SECRET ?? "", + }, + timeout: 5000, }, ); - - await Promise.allSettled(webhookPromises); } catch (error) { - console.log("Webhook delivery failed"); + console.log("Awareness ingest delivery failed"); } } @@ -404,9 +363,7 @@ export class GraphQLServer { parsed: parsedFromEnvelopes, }; - // Deliver webhooks for create operation - const requestingPlatform = - context.tokenPayload?.platform || null; + // Forward the awareness packet for create operation const webhookPayload = { id: result.metaEnvelope.id, w3id: context.eName, @@ -415,13 +372,11 @@ export class GraphQLServer { schemaId: input.ontology, }; - // Delayed webhook delivery to prevent ping-pong - setTimeout(() => { - this.deliverWebhooks( - requestingPlatform, - webhookPayload, - ); - }, 3_000); + // Fire-and-forget ingest to AaaS + this.notifyAwareness( + webhookPayload, + context.tokenPayload?.platform || null, + ); // Send push notifications for new messages console.log(`[NOTIF] createMetaEnvelope ontology: "${input.ontology}"`); @@ -553,8 +508,6 @@ export class GraphQLServer { // would make the receiver lose every untouched // field (e.g. a read-receipt update would wipe // participantIds on the receiver side). - const requestingPlatform = - context.tokenPayload?.platform || null; const webhookPayload = { id, w3id: context.eName, @@ -563,10 +516,10 @@ export class GraphQLServer { schemaId: input.ontology, }; - // Fire and forget webhook delivery - this.deliverWebhooks( - requestingPlatform, + // Fire-and-forget ingest to AaaS + this.notifyAwareness( webhookPayload, + context.tokenPayload?.platform || null, ); // Log envelope operation best-effort @@ -777,10 +730,8 @@ export class GraphQLServer { }); successCount++; - // Deliver webhooks if not skipping + // Forward awareness packet if not skipping if (!shouldSkipWebhooks) { - const requestingPlatform = - context.tokenPayload?.platform || null; const webhookPayload = { id: result.metaEnvelope.id, w3id: context.eName, @@ -789,12 +740,12 @@ export class GraphQLServer { schemaId: input.ontology, }; - // Fire and forget webhook delivery - this.deliverWebhooks( - requestingPlatform, + // Fire-and-forget ingest to AaaS + this.notifyAwareness( webhookPayload, + context.tokenPayload?.platform || null, ).catch((err) => { - console.error(`[WEBHOOK] Delivery failed for bulk-create envelope ${result.metaEnvelope.id}:`, err); + console.error(`[WEBHOOK] AaaS ingest failed for bulk-create envelope ${result.metaEnvelope.id}:`, err); }); } @@ -949,8 +900,6 @@ export class GraphQLServer { ), ); - const requestingPlatform = - context.tokenPayload?.platform || null; const webhookPayload = { id: metaEnvelopeId, w3id: context.eName, @@ -959,12 +908,10 @@ export class GraphQLServer { schemaId: BINDING_DOCUMENT_ONTOLOGY, }; - setTimeout(() => { - this.deliverWebhooks( - requestingPlatform, - webhookPayload, - ); - }, 3_000); + this.notifyAwareness( + webhookPayload, + context.tokenPayload?.platform || null, + ); return { bindingDocument: result.bindingDocument, @@ -1059,8 +1006,6 @@ export class GraphQLServer { ), ); - const requestingPlatform = - context.tokenPayload?.platform || null; const webhookPayload = { id: input.bindingDocumentId, w3id: context.eName, @@ -1069,12 +1014,10 @@ export class GraphQLServer { schemaId: BINDING_DOCUMENT_ONTOLOGY, }; - setTimeout(() => { - this.deliverWebhooks( - requestingPlatform, - webhookPayload, - ); - }, 3_000); + this.notifyAwareness( + webhookPayload, + context.tokenPayload?.platform || null, + ); return { bindingDocument: result, @@ -1138,9 +1081,10 @@ export class GraphQLServer { parsed: input.payload, }; - // Deliver webhooks for create operation - const requestingPlatform = - context.tokenPayload?.platform || null; + // Forward the awareness packet for create operation. + // The requesting platform is passed so AaaS can skip + // delivering the packet back to its origin — the same + // ping-pong guard the old fanout enforced here. const webhookPayload = { id: result.metaEnvelope.id, w3id: context.eName, @@ -1149,22 +1093,10 @@ export class GraphQLServer { schemaId: input.ontology, }; - /** - * To whoever who reads this in the future please don't - * remove this delay as this prevents a VERY horrible - * disgusting edge case, where if a platform's URL is - * not determinable the webhook to the same platform as - * the one who sent off the request gets sent and that - * is not an ideal case trust me I've suffered, it - * causes an absolutely beautiful error where you get - * stuck in what I like to call webhook ping-pong - */ - setTimeout(() => { - this.deliverWebhooks( - requestingPlatform, - webhookPayload, - ); - }, 3_000); + this.notifyAwareness( + webhookPayload, + context.tokenPayload?.platform || null, + ); // Send push notifications for new messages console.log(`[NOTIF] storeMetaEnvelope ontology: "${input.ontology}"`); @@ -1249,8 +1181,6 @@ export class GraphQLServer { // resolver above — sending input.payload (the // partial diff) would make receivers clobber their // own untouched fields. - const requestingPlatform = - context.tokenPayload?.platform || null; const webhookPayload = { id: id, w3id: context.eName, @@ -1259,10 +1189,10 @@ export class GraphQLServer { schemaId: input.ontology, }; - // Fire and forget webhook delivery - this.deliverWebhooks( - requestingPlatform, + // Fire-and-forget ingest to AaaS + this.notifyAwareness( webhookPayload, + context.tokenPayload?.platform || null, ); // Log envelope operation best-effort (do not fail mutation) diff --git a/services/awareness-service/api/src/services/IngestService.ts b/services/awareness-service/api/src/services/IngestService.ts index 7317677c0..850be4d42 100644 --- a/services/awareness-service/api/src/services/IngestService.ts +++ b/services/awareness-service/api/src/services/IngestService.ts @@ -4,6 +4,15 @@ import { Packet } from "../database/entities/Packet"; import type { AwarenessPayload } from "../types"; import { SubscriptionMatcher } from "./SubscriptionMatcher"; +/** Returns the normalised origin of a URL, or null if it cannot be parsed. */ +function safeOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + /** * Persists an incoming awareness packet and queues a webhook delivery for every * subscription that matches it. Re-ingesting the same packet is idempotent: the @@ -30,7 +39,19 @@ export class IngestService { await packetRepo.upsert(packet, ["id"]); - const subscriptions = await this.matcher.match(packet); + let subscriptions = await this.matcher.match(packet); + + // Skip delivering the packet back to the platform that triggered it - + // the same ping-pong guard evault-core's old fanout enforced. + if (payload.requestingPlatform) { + const origin = safeOrigin(payload.requestingPlatform); + if (origin) { + subscriptions = subscriptions.filter( + (sub) => safeOrigin(sub.targetUrl) !== origin, + ); + } + } + if (subscriptions.length === 0) { return { packetId: packet.id, deliveriesQueued: 0 }; } diff --git a/services/awareness-service/api/src/types.ts b/services/awareness-service/api/src/types.ts index f76d2fe1d..31147d7be 100644 --- a/services/awareness-service/api/src/types.ts +++ b/services/awareness-service/api/src/types.ts @@ -8,6 +8,11 @@ export interface AwarenessPayload { data?: Record | null; schemaId: string; operation?: "create" | "update" | "delete"; + /** + * The platform that triggered the change, if known. Used only to skip + * delivering the packet back to its origin; never persisted or delivered. + */ + requestingPlatform?: string | null; } declare global { From bee5fdbf1a60098e92ad480c31ba5a021204b2b8 Mon Sep 17 00:00:00 2001 From: coodos Date: Sat, 9 May 2026 15:10:00 +0530 Subject: [PATCH 07/24] feat(awareness-service): public SvelteKit portal Add the portal: W3DS QR login, an access application form, a consumer dashboard (API key rotation, webhook subscription management, recent delivery status), an admin queue to approve/reject applications, and a dead-letter view with replay. Mirrors the enotary SvelteKit + Tailwind stack. --- .../awareness-service/portal/package.json | 27 ++ services/awareness-service/portal/src/app.css | 11 + .../awareness-service/portal/src/app.d.ts | 5 + .../awareness-service/portal/src/app.html | 13 + .../awareness-service/portal/src/lib/api.ts | 39 +++ .../portal/src/lib/session.ts | 20 ++ .../portal/src/routes/+layout.svelte | 32 ++ .../portal/src/routes/+page.svelte | 81 +++++ .../portal/src/routes/admin/+page.svelte | 127 ++++++++ .../routes/admin/dead-letters/+page.svelte | 111 +++++++ .../portal/src/routes/apply/+page.svelte | 129 ++++++++ .../portal/src/routes/dashboard/+page.svelte | 287 ++++++++++++++++++ .../awareness-service/portal/svelte.config.js | 14 + .../awareness-service/portal/tsconfig.json | 14 + .../awareness-service/portal/vite.config.ts | 7 + 15 files changed, 917 insertions(+) create mode 100644 services/awareness-service/portal/package.json create mode 100644 services/awareness-service/portal/src/app.css create mode 100644 services/awareness-service/portal/src/app.d.ts create mode 100644 services/awareness-service/portal/src/app.html create mode 100644 services/awareness-service/portal/src/lib/api.ts create mode 100644 services/awareness-service/portal/src/lib/session.ts create mode 100644 services/awareness-service/portal/src/routes/+layout.svelte create mode 100644 services/awareness-service/portal/src/routes/+page.svelte create mode 100644 services/awareness-service/portal/src/routes/admin/+page.svelte create mode 100644 services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte create mode 100644 services/awareness-service/portal/src/routes/apply/+page.svelte create mode 100644 services/awareness-service/portal/src/routes/dashboard/+page.svelte create mode 100644 services/awareness-service/portal/svelte.config.js create mode 100644 services/awareness-service/portal/tsconfig.json create mode 100644 services/awareness-service/portal/vite.config.ts diff --git a/services/awareness-service/portal/package.json b/services/awareness-service/portal/package.json new file mode 100644 index 000000000..f8ea9170c --- /dev/null +++ b/services/awareness-service/portal/package.json @@ -0,0 +1,27 @@ +{ + "name": "awareness-portal", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.2.6" + }, + "dependencies": { + "svelte-qrcode": "^1.0.1" + } +} diff --git a/services/awareness-service/portal/src/app.css b/services/awareness-service/portal/src/app.css new file mode 100644 index 000000000..68d0255d2 --- /dev/null +++ b/services/awareness-service/portal/src/app.css @@ -0,0 +1,11 @@ +@import "tailwindcss"; + +body { + font-family: + Inter, + system-ui, + -apple-system, + sans-serif; + background: #f8fafc; + color: #111827; +} diff --git a/services/awareness-service/portal/src/app.d.ts b/services/awareness-service/portal/src/app.d.ts new file mode 100644 index 000000000..9213ba0a1 --- /dev/null +++ b/services/awareness-service/portal/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/services/awareness-service/portal/src/app.html b/services/awareness-service/portal/src/app.html new file mode 100644 index 000000000..5b5e00420 --- /dev/null +++ b/services/awareness-service/portal/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Awareness as a Service + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/services/awareness-service/portal/src/lib/api.ts b/services/awareness-service/portal/src/lib/api.ts new file mode 100644 index 000000000..0b14bda8d --- /dev/null +++ b/services/awareness-service/portal/src/lib/api.ts @@ -0,0 +1,39 @@ +import { env } from "$env/dynamic/public"; + +/** Base URL of the AaaS API. Falls back to the default local dev port. */ +export const API_BASE = + env.PUBLIC_AWARENESS_API_URL ?? "http://localhost:4100"; + +export interface ApiError { + error: string; +} + +/** Thin fetch wrapper that attaches the bearer token and parses JSON. */ +export async function api( + path: string, + options: { + method?: string; + body?: unknown; + token?: string | null; + } = {}, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + + const res = await fetch(`${API_BASE}${path}`, { + method: options.method ?? "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await res.text(); + const data = text ? JSON.parse(text) : {}; + if (!res.ok) { + throw new Error((data as ApiError).error ?? `request failed (${res.status})`); + } + return data as T; +} diff --git a/services/awareness-service/portal/src/lib/session.ts b/services/awareness-service/portal/src/lib/session.ts new file mode 100644 index 000000000..e31c4b1e9 --- /dev/null +++ b/services/awareness-service/portal/src/lib/session.ts @@ -0,0 +1,20 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; + +const STORAGE_KEY = "aaas_session_token"; + +/** The W3DS portal session JWT, persisted in localStorage. */ +export const sessionToken = writable( + browser ? localStorage.getItem(STORAGE_KEY) : null, +); + +if (browser) { + sessionToken.subscribe((token) => { + if (token) localStorage.setItem(STORAGE_KEY, token); + else localStorage.removeItem(STORAGE_KEY); + }); +} + +export function logout(): void { + sessionToken.set(null); +} diff --git a/services/awareness-service/portal/src/routes/+layout.svelte b/services/awareness-service/portal/src/routes/+layout.svelte new file mode 100644 index 000000000..04b5cd6b3 --- /dev/null +++ b/services/awareness-service/portal/src/routes/+layout.svelte @@ -0,0 +1,32 @@ + + +
+
+ +
+
+ {@render children()} +
+
diff --git a/services/awareness-service/portal/src/routes/+page.svelte b/services/awareness-service/portal/src/routes/+page.svelte new file mode 100644 index 000000000..1c65f897a --- /dev/null +++ b/services/awareness-service/portal/src/routes/+page.svelte @@ -0,0 +1,81 @@ + + +
+

Awareness as a Service

+

+ Query MetaEnvelope awareness packets and register webhook subscriptions + scoped by ontology and eVault. Sign in with your W3DS identity to apply + for access. +

+ + {#if error} +

{error}

+ {/if} + + {#if !uri} + + {:else} +
+

+ Scan with your eID wallet to sign in. +

+
+ +
+ {#if polling} +

Waiting for signature…

+ {/if} +
+ {/if} +
diff --git a/services/awareness-service/portal/src/routes/admin/+page.svelte b/services/awareness-service/portal/src/routes/admin/+page.svelte new file mode 100644 index 000000000..1a40ebc56 --- /dev/null +++ b/services/awareness-service/portal/src/routes/admin/+page.svelte @@ -0,0 +1,127 @@ + + +
+
+

Pending applications

+ + Dead letters → + +
+ + {#if notAdmin} +

+ Your eName is not in the admin allowlist. +

+ {:else if error} +

{error}

+ {:else if loading} +

Loading…

+ {:else} +
    + {#each applications as app (app.id)} +
  • +
    +
    +

    + {app.consumer?.name ?? app.consumer?.ename} +

    +

    {app.consumer?.ename}

    +

    + {app.consumer?.contactEmail ?? "no email"} · + {app.consumer?.webhookBaseUrl ?? "no webhook URL"} +

    +
    +
    + + +
    +
    + {#if app.justification} +

    {app.justification}

    + {/if} + {#if app.requestedOntologies.length} +

    + Requested: {app.requestedOntologies.join(", ")} +

    + {/if} +
  • + {:else} +
  • No pending applications.
  • + {/each} +
+ {/if} +
diff --git a/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte b/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte new file mode 100644 index 000000000..624d79317 --- /dev/null +++ b/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte @@ -0,0 +1,111 @@ + + +
+

Dead-lettered deliveries

+

+ Deliveries that exhausted every retry attempt. Replay re-queues them. +

+ + {#if notAdmin} +

+ Your eName is not in the admin allowlist. +

+ {:else if error} +

{error}

+ {:else if loading} +

Loading…

+ {:else} +
    + {#each deadLetters as dl (dl.id)} +
  • +
    +
    +

    {dl.targetUrl}

    +

    + packet {dl.packetId} · {dl.totalAttempts} attempts · + status {dl.lastResponseStatus ?? "n/a"} +

    + {#if dl.lastError} +

    {dl.lastError}

    + {/if} +
    + {#if dl.resolved} + resolved + {:else} + + {/if} +
    +
  • + {:else} +
  • No dead letters.
  • + {/each} +
+ {/if} +
diff --git a/services/awareness-service/portal/src/routes/apply/+page.svelte b/services/awareness-service/portal/src/routes/apply/+page.svelte new file mode 100644 index 000000000..ca2d4c37f --- /dev/null +++ b/services/awareness-service/portal/src/routes/apply/+page.svelte @@ -0,0 +1,129 @@ + + +
+

Apply for access

+

+ Submit your platform details. A whitelisted admin will review the + request before you can poll packets or register webhooks. +

+ + {#if existingStatus} +

+ You already have an application — current status: + {existingStatus}. Submitting again updates it. +

+ {/if} + {#if error} +

{error}

+ {/if} + +
+ + + + + + +
+
diff --git a/services/awareness-service/portal/src/routes/dashboard/+page.svelte b/services/awareness-service/portal/src/routes/dashboard/+page.svelte new file mode 100644 index 000000000..a38d54de9 --- /dev/null +++ b/services/awareness-service/portal/src/routes/dashboard/+page.svelte @@ -0,0 +1,287 @@ + + +
+

Dashboard

+ + {#if error} +

{error}

+ {/if} + + {#if loading} +

Loading…

+ {:else if !consumer} +
+

+ You have not applied for access yet. +

+ + Apply for access + +
+ {:else if consumer.status !== "approved"} +
+

+ Application status: + {applicationStatus ?? consumer.status} +

+

+ An admin must approve your application before you can poll + packets or register webhooks. +

+
+ {:else} + +
+
+

API keys

+ +
+ {#if newKey} +

+ Copy this key now — it is shown only once: + {newKey} +

+ {/if} +
    + {#each apiKeys as key (key.id)} +
  • + {key.keyPrefix}… + {#if key.revoked} + revoked + {:else} + + {/if} +
  • + {:else} +
  • No keys yet.
  • + {/each} +
+
+ + +
+

Webhook subscriptions

+
+ + + +
+ +
    + {#each subscriptions as sub (sub.id)} +
  • +
    + {sub.targetUrl} + +
    +

    + ontologies: {sub.ontologyFilter.length + ? sub.ontologyFilter.join(", ") + : "all"} · eVaults: {sub.evaultFilter.length + ? sub.evaultFilter.join(", ") + : "all"} · {sub.active ? "active" : "inactive"} +

    +
  • + {:else} +
  • No subscriptions.
  • + {/each} +
+
+ + +
+

Recent deliveries

+
    + {#each deliveries as d (d.id)} +
  • + {d.packetId} + + {d.status} ({d.attempts}) + +
  • + {:else} +
  • No deliveries yet.
  • + {/each} +
+
+ {/if} +
diff --git a/services/awareness-service/portal/svelte.config.js b/services/awareness-service/portal/svelte.config.js new file mode 100644 index 000000000..6d486e749 --- /dev/null +++ b/services/awareness-service/portal/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + env: { + dir: "../../../", + }, + }, +}; + +export default config; diff --git a/services/awareness-service/portal/tsconfig.json b/services/awareness-service/portal/tsconfig.json new file mode 100644 index 000000000..104691d2d --- /dev/null +++ b/services/awareness-service/portal/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/services/awareness-service/portal/vite.config.ts b/services/awareness-service/portal/vite.config.ts new file mode 100644 index 000000000..deb417265 --- /dev/null +++ b/services/awareness-service/portal/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from "@tailwindcss/vite"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], +}); From d3ccfc126f46e3ded8d3407aaf1d5ed6bd1b57d4 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 12:00:00 +0530 Subject: [PATCH 08/24] chore(awareness-service): init migration, workspace and env wiring - Add the initial TypeORM migration creating all AaaS tables and indexes. - Register services/*/* in the pnpm workspace so the api and portal packages resolve. - Add AaaS environment variables to .env.example. - Type jsonb columns as any so TypeORM's deep-partial insert types accept full packet payloads. - Add the service README. --- .env.example | 24 +++ pnpm-lock.yaml | 102 ++++++++++- pnpm-workspace.yaml | 1 + services/awareness-service/README.md | 53 ++++++ .../api/src/database/entities/DeadLetter.ts | 2 +- .../api/src/database/entities/Packet.ts | 3 +- .../database/migrations/1715200000000-Init.ts | 170 ++++++++++++++++++ .../api/src/services/DeliveryEngine.ts | 2 +- 8 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 services/awareness-service/README.md create mode 100644 services/awareness-service/api/src/database/migrations/1715200000000-Init.ts diff --git a/.env.example b/.env.example index 4dd9bf300..db6914d3d 100644 --- a/.env.example +++ b/.env.example @@ -129,3 +129,27 @@ FILE_MANAGER_JWT_SECRET="secret" CHARTER_JWT_SECRET="secret" PICTIQUE_JWT_SECRET="secret" + +# Awareness as a Service (AaaS) +# Connection string for the AaaS Postgres database +AWARENESS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/awareness" +AWARENESS_API_PORT=4100 +# Public base URL of the AaaS API (used to build W3DS auth callbacks) +AWARENESS_PUBLIC_URL="http://localhost:4100" +# Shared secret evault-core must present on POST /ingest +AWARENESS_INGEST_SECRET="replace-with-a-strong-secret" +# Where evault-core forwards every awareness packet +AWARENESS_SERVICE_URL="http://localhost:4100" +# Comma-separated eNames allowed to act as AaaS portal admins +AAAS_ADMIN_ENAMES="" +# Secret used to sign AaaS portal session JWTs +AAAS_JWT_SECRET="replace-with-a-strong-secret" +# Webhook delivery tuning +AWARENESS_MAX_ATTEMPTS=8 +AWARENESS_DELIVERY_POLL_MS=2000 +# Neo4j source for the one-time backfill (evault-core's graph) +AWARENESS_NEO4J_URI="bolt://localhost:7687" +AWARENESS_NEO4J_USER="neo4j" +AWARENESS_NEO4J_PASSWORD="your-neo4j-password" +# Portal -> API base URL +PUBLIC_AWARENESS_API_URL="http://localhost:4100" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f3432c33..0f1f7d41a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3971,6 +3971,106 @@ importers: specifier: ^5.3.3 version: 5.8.2 + services/awareness-service: {} + + services/awareness-service/api: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.6 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + neo4j-driver: + specifier: ^5.28.1 + version: 5.28.3 + pg: + specifier: ^8.11.3 + version: 8.20.0 + reflect-metadata: + specifier: ^0.2.1 + version: 0.2.2 + signature-validator: + specifier: workspace:* + version: link:../../../infrastructure/signature-validator + typeorm: + specifier: ^0.3.24 + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.9.3)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.10 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + '@types/pg': + specifier: ^8.11.2 + version: 8.18.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + nodemon: + specifier: ^3.0.3 + version: 3.1.14 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.26)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + services/awareness-service/portal: + dependencies: + svelte-qrcode: + specifier: ^1.0.1 + version: 1.0.1 + devDependencies: + '@sveltejs/adapter-node': + specifier: ^5.2.12 + version: 5.5.4(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.11)(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@sveltejs/kit': + specifier: ^2.16.0 + version: 2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.11)(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.1(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + svelte: + specifier: ^5.0.0 + version: 5.53.11 + svelte-check: + specifier: ^4.0.0 + version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.1 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + services/ontology: dependencies: cors: @@ -19782,7 +19882,7 @@ packages: uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4f615292b..b3e69f2e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - platforms/*/* - infrastructure/* - services/* + - services/*/* - notification-trigger - tests/ - docs/ diff --git a/services/awareness-service/README.md b/services/awareness-service/README.md new file mode 100644 index 000000000..38c7e6486 --- /dev/null +++ b/services/awareness-service/README.md @@ -0,0 +1,53 @@ +# Awareness as a Service (AaaS) + +AaaS is the single fanout point for MetaEnvelope awareness packets. It replaces +evault-core's built-in webhook fanout: evault-core now makes one POST to +`AWARENESS_SERVICE_URL/ingest` per change, and AaaS owns persistence, polling, +subscription matching and retrying webhook delivery. + +## Packages + +- `api/` — Express + TypeORM (Postgres) service. +- `portal/` — SvelteKit + Tailwind public portal. + +## What it does + +1. **Ingest** — `POST /ingest` receives every awareness packet from evault-core + (shared-secret auth) and persists it. +2. **Poll** — `GET /api/packets` lets approved consumers query packet history by + ontology, eVault and time range, with cursor pagination. +3. **Subscribe** — `/api/subscriptions` registers webhook subscriptions filtered + by ontology and eVault. Delivered payloads match the legacy evault-core + webhook format exactly. +4. **Deliver** — a background engine drains the delivery queue with exponential + backoff; exhausted deliveries land in a dead-letter table. +5. **Portal** — platforms log in with W3DS, apply for access, and admins + (`AAAS_ADMIN_ENAMES`) approve them. Approved consumers get API keys. + +## Setup + +```sh +# 1. Create the Postgres database referenced by AWARENESS_DATABASE_URL +# 2. Run migrations +pnpm --filter awareness-service-api build +pnpm --filter awareness-service-api migration:run + +# 3. One-time backfill from evault-core's Neo4j (same node) +pnpm --filter awareness-service-api backfill + +# 4. Start the API (also seeds catch-all subscriptions on launch) +pnpm --filter awareness-service-api dev + +# 5. Start the portal +pnpm --filter awareness-portal dev +``` + +Then set `AWARENESS_SERVICE_URL` and `AWARENESS_INGEST_SECRET` for evault-core +so it forwards packets here. + +## Backward compatibility + +On launch AaaS seeds a catch-all subscription for every platform currently in +the registry, so existing webhook receivers keep getting every packet at +`/api/webhook` with no change. Consumers can later narrow to specific +ontologies / eVaults. diff --git a/services/awareness-service/api/src/database/entities/DeadLetter.ts b/services/awareness-service/api/src/database/entities/DeadLetter.ts index 5f5a70069..db8d3d77d 100644 --- a/services/awareness-service/api/src/database/entities/DeadLetter.ts +++ b/services/awareness-service/api/src/database/entities/DeadLetter.ts @@ -29,7 +29,7 @@ export class DeadLetter { /** The exact body that failed to deliver. */ @Column({ type: "jsonb" }) - payload!: Record; + payload!: any; @Column({ type: "varchar" }) targetUrl!: string; diff --git a/services/awareness-service/api/src/database/entities/Packet.ts b/services/awareness-service/api/src/database/entities/Packet.ts index 09b3d4ff9..04e9561d0 100644 --- a/services/awareness-service/api/src/database/entities/Packet.ts +++ b/services/awareness-service/api/src/database/entities/Packet.ts @@ -33,8 +33,9 @@ export class Packet { @Column({ type: "varchar", nullable: true }) w3id!: string | null; + // typed as `any` so TypeORM's deep-partial insert/upsert types accept it. @Column({ type: "jsonb", nullable: true }) - data!: Record | null; + data!: any; @Column({ type: "varchar", default: "create" }) operation!: PacketOperation; diff --git a/services/awareness-service/api/src/database/migrations/1715200000000-Init.ts b/services/awareness-service/api/src/database/migrations/1715200000000-Init.ts new file mode 100644 index 000000000..098471c09 --- /dev/null +++ b/services/awareness-service/api/src/database/migrations/1715200000000-Init.ts @@ -0,0 +1,170 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Initial schema for Awareness as a Service: awareness packets, consumers and + * their access applications, API keys, webhook subscriptions, the delivery + * queue and the dead-letter table. + */ +export class Init1715200000000 implements MigrationInterface { + name = "Init1715200000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "packets" ( + "id" varchar NOT NULL, + "ontology" varchar NOT NULL, + "evaultPublicKey" varchar, + "w3id" varchar, + "data" jsonb, + "operation" varchar NOT NULL DEFAULT 'create', + "receivedAt" timestamptz NOT NULL DEFAULT now(), + "createdAt" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_packets" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_packets_ontology" ON "packets" ("ontology")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_packets_evault_pubkey" ON "packets" ("evaultPublicKey")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_packets_w3id" ON "packets" ("w3id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_packets_received" ON "packets" ("receivedAt")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_packets_ontology_received" ON "packets" ("ontology", "receivedAt")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_packets_received_id" ON "packets" ("receivedAt", "id")`, + ); + + await queryRunner.query(` + CREATE TABLE "consumers" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "ename" varchar NOT NULL, + "name" varchar, + "contactEmail" varchar, + "status" varchar NOT NULL DEFAULT 'pending', + "webhookBaseUrl" varchar, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "approvedAt" timestamptz, + CONSTRAINT "PK_consumers" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "idx_consumers_ename" ON "consumers" ("ename")`, + ); + + await queryRunner.query(` + CREATE TABLE "access_applications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "consumerId" uuid NOT NULL, + "justification" text, + "requestedOntologies" text array NOT NULL DEFAULT '{}', + "status" varchar NOT NULL DEFAULT 'pending', + "reviewedByEname" varchar, + "reviewNote" text, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "reviewedAt" timestamptz, + CONSTRAINT "PK_access_applications" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_applications_consumer" ON "access_applications" ("consumerId")`, + ); + + await queryRunner.query(` + CREATE TABLE "api_keys" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "consumerId" uuid NOT NULL, + "keyHash" varchar NOT NULL, + "keyPrefix" varchar NOT NULL, + "revoked" boolean NOT NULL DEFAULT false, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "lastUsedAt" timestamptz, + CONSTRAINT "PK_api_keys" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_api_keys_consumer" ON "api_keys" ("consumerId")`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "idx_api_keys_hash" ON "api_keys" ("keyHash")`, + ); + + await queryRunner.query(` + CREATE TABLE "subscriptions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "consumerId" uuid NOT NULL, + "targetUrl" varchar NOT NULL, + "ontologyFilter" text array NOT NULL DEFAULT '{}', + "evaultFilter" text array NOT NULL DEFAULT '{}', + "isCatchAll" boolean NOT NULL DEFAULT false, + "active" boolean NOT NULL DEFAULT true, + "secret" varchar, + "createdAt" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_subscriptions" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_subscriptions_consumer" ON "subscriptions" ("consumerId")`, + ); + + await queryRunner.query(` + CREATE TABLE "deliveries" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "subscriptionId" uuid NOT NULL, + "packetId" varchar NOT NULL, + "status" varchar NOT NULL DEFAULT 'pending', + "attempts" integer NOT NULL DEFAULT 0, + "nextAttemptAt" timestamptz NOT NULL DEFAULT now(), + "lastError" text, + "lastResponseStatus" integer, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "deliveredAt" timestamptz, + CONSTRAINT "PK_deliveries" PRIMARY KEY ("id"), + CONSTRAINT "uq_delivery_subscription_packet" UNIQUE ("subscriptionId", "packetId") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_deliveries_subscription" ON "deliveries" ("subscriptionId")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_deliveries_next_attempt" ON "deliveries" ("nextAttemptAt")`, + ); + + await queryRunner.query(` + CREATE TABLE "dead_letters" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "deliveryId" uuid NOT NULL, + "subscriptionId" uuid NOT NULL, + "packetId" varchar NOT NULL, + "consumerId" uuid NOT NULL, + "payload" jsonb NOT NULL, + "targetUrl" varchar NOT NULL, + "totalAttempts" integer NOT NULL, + "lastError" text, + "lastResponseStatus" integer, + "resolved" boolean NOT NULL DEFAULT false, + "createdAt" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_dead_letters" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE INDEX "idx_dead_letters_resolved" ON "dead_letters" ("resolved")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "dead_letters"`); + await queryRunner.query(`DROP TABLE "deliveries"`); + await queryRunner.query(`DROP TABLE "subscriptions"`); + await queryRunner.query(`DROP TABLE "api_keys"`); + await queryRunner.query(`DROP TABLE "access_applications"`); + await queryRunner.query(`DROP TABLE "consumers"`); + await queryRunner.query(`DROP TABLE "packets"`); + } +} diff --git a/services/awareness-service/api/src/services/DeliveryEngine.ts b/services/awareness-service/api/src/services/DeliveryEngine.ts index fc8cf52b3..9ef60412f 100644 --- a/services/awareness-service/api/src/services/DeliveryEngine.ts +++ b/services/awareness-service/api/src/services/DeliveryEngine.ts @@ -148,7 +148,7 @@ export class DeliveryEngine { subscriptionId: delivery.subscriptionId, packetId: delivery.packetId, consumerId: subscription?.consumerId ?? delivery.subscriptionId, - payload: (ctx?.payload ?? {}) as Record, + payload: (ctx?.payload ?? {}) as any, targetUrl: subscription?.targetUrl ?? "", totalAttempts: attempts, lastError: message, From 5fa4436d2c98c6f7ad8d0f9f91c9332c18df0a11 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 16:30:00 +0530 Subject: [PATCH 09/24] docs(awareness-service): Scalar API reference and service docs - Add a full OpenAPI 3.1 document covering every AaaS endpoint, served raw at GET /openapi.json and rendered as an interactive Scalar reference at GET /docs. - Add a Services section to the docs site with an Awareness as a Service page covering architecture, packet format, capabilities, auth, migration and configuration. - Note the docs endpoints in the service README. --- docs/docs/Services/Awareness-as-a-Service.md | 177 ++++ docs/docs/Services/_category_.json | 4 + pnpm-lock.yaml | 162 +-- services/awareness-service/README.md | 11 + services/awareness-service/api/package.json | 1 + services/awareness-service/api/src/index.ts | 15 + services/awareness-service/api/src/openapi.ts | 980 ++++++++++++++++++ 7 files changed, 1279 insertions(+), 71 deletions(-) create mode 100644 docs/docs/Services/Awareness-as-a-Service.md create mode 100644 docs/docs/Services/_category_.json create mode 100644 services/awareness-service/api/src/openapi.ts diff --git a/docs/docs/Services/Awareness-as-a-Service.md b/docs/docs/Services/Awareness-as-a-Service.md new file mode 100644 index 000000000..6633bd395 --- /dev/null +++ b/docs/docs/Services/Awareness-as-a-Service.md @@ -0,0 +1,177 @@ +--- +sidebar_position: 1 +--- + +# Awareness as a Service (AaaS) + +Awareness as a Service is the single fanout point for MetaEnvelope **awareness +packets**. It replaces the webhook fanout that previously lived inside +evault-core, and adds a queryable history, granular subscriptions, and an +access-controlled public portal. + +## Why it exists + +Before AaaS, every eVault fanned out webhooks itself: on each MetaEnvelope +create/update it queried the registry for every platform and POSTed the change +to all of them. That design had three problems: + +- **Undifferentiated** — every platform received every packet, regardless of + whether it cared about that ontology. +- **Unqueryable** — there was no way to poll history or catch up after + downtime; a missed webhook was simply lost. +- **Ungoverned** — any registered platform received everything; there was no + access gate. + +AaaS fixes all three. evault-core now makes **one** POST per change to +`AWARENESS_SERVICE_URL/ingest`, and AaaS owns persistence, polling, subscription +matching, and retrying delivery. + +## Architecture + +``` + ┌─────────────────────────────┐ + evault-core ──POST───▶ │ AaaS /ingest │ + (per change) │ • persist packet │ + │ • match subscriptions │ + │ • queue deliveries │ + └──────────────┬──────────────┘ + │ + ┌────────────────────────────┼───────────────────────────┐ + ▼ ▼ ▼ + GET /api/packets Delivery engine Portal (SvelteKit) + (poll by ontology / (retry + backoff, • W3DS login + eVault / time range) dead-letter) • apply for access + POST /api/webhook • admin approval +``` + +The API is **Express + TypeORM + Postgres**; the portal is **SvelteKit + +Tailwind**. Both live in `services/awareness-service/`. + +## Awareness packet format + +The packet evault-core POSTs to `/ingest` — and the body AaaS delivers to +webhook subscribers — is unchanged from the legacy evault-core webhook, so +existing receivers need no changes: + +```json +{ + "id": "", + "w3id": "", + "evaultPublicKey": "", + "data": { "...": "the MetaEnvelope payload" }, + "schemaId": "" +} +``` + +`/ingest` additionally accepts a `requestingPlatform` field, used only to skip +delivering a packet back to its origin (the ping-pong guard the old fanout +enforced). It is never persisted or delivered. + +## Capabilities + +### 1. Polling query API + +`GET /api/packets` lets an approved consumer query the awareness history, +filtered by `ontology` (comma-separated), `evault`, and a `from`/`to` time +range. Results are ordered by receive time and paged with an opaque cursor: + +``` +GET /api/packets?ontology=&from=2026-05-01T00:00:00Z&limit=100 +Authorization: Bearer aaas_ +``` + +The response carries `packets`, `hasMore`, and `nextCursor` — pass `nextCursor` +back as `cursor` to page forward. + +### 2. Dynamic webhook subscriptions + +`POST /api/subscriptions` registers a webhook subscription scoped by ontology +and eVault. Empty filter arrays mean "everything": + +```json +{ + "targetUrl": "https://my-platform.example/api/webhook", + "ontologyFilter": ["", ""], + "evaultFilter": [""] +} +``` + +A consumer manages only its own subscriptions (`GET`, `PATCH`, `DELETE`). If a +subscription has a `secret`, each delivery carries an `x-aaas-signature` header +(HMAC-SHA256 of the body). + +### 3. Retrying delivery + dead-letters + +A background engine drains the delivery queue. Failed deliveries are retried +with exponential backoff (30s → 1m → 2m → 5m → 15m → 1h → 6h → 24h). After +`AWARENESS_MAX_ATTEMPTS` attempts the delivery is moved to a **dead-letter** +table, visible to admins in the portal, where it can be replayed. + +### 4. Public access portal + +Platforms log in with **W3DS** (scan a `w3ds://auth` deeplink with the eID +wallet), submit an access application, and wait for an admin to approve it. +Admins are identified by an env-var allowlist of eNames (`AAAS_ADMIN_ENAMES`). +Once approved, a consumer issues API keys from its dashboard and manages +subscriptions and delivery status there. + +## Authentication + +| Surface | Credential | +| --- | --- | +| `/ingest` | `x-ingest-secret` header (shared with evault-core) | +| `/api/packets`, `/api/subscriptions`, `/api/me/*` | `Authorization: Bearer` — an issued API key (`aaas_…`) **or** a W3DS portal session JWT | +| `/api/applications/*` | W3DS portal session JWT | +| `/api/admin/*` | W3DS portal session JWT whose eName is in `AAAS_ADMIN_ENAMES` | + +API keys are stored only as SHA-256 hashes; the plaintext is shown exactly once +on creation. + +## API reference + +The API serves an interactive **Scalar** reference and a raw OpenAPI document: + +- `GET /docs` — Scalar API reference UI +- `GET /openapi.json` — the OpenAPI 3.1 document + +## Migration from the old fanout + +AaaS is designed to be dropped in with **zero receiver-side changes**: + +1. **Backfill.** AaaS runs on the same node as evault-core's Neo4j. The + `backfill` script reads existing MetaEnvelopes straight from the graph and + seeds the `packets` table (history only — it does not queue deliveries). +2. **Catch-all seeding.** On every launch, AaaS ensures each platform currently + in the registry has an approved consumer and a catch-all subscription + pointing at `/api/webhook`. Existing platforms therefore keep + receiving every packet exactly as before, and can later narrow their + subscriptions to specific ontologies or eVaults. +3. **evault-core switch.** evault-core's `deliverWebhooks`/`getActivePlatforms` + are removed; a single `notifyAwareness` POST forwards each packet to AaaS. + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `AWARENESS_DATABASE_URL` | Postgres connection string for AaaS | +| `AWARENESS_API_PORT` | API listen port (default 4100) | +| `AWARENESS_PUBLIC_URL` | Public base URL, used for W3DS auth callbacks | +| `AWARENESS_INGEST_SECRET` | Shared secret for `/ingest` | +| `AWARENESS_SERVICE_URL` | (evault-core) where to POST packets | +| `AAAS_ADMIN_ENAMES` | Comma-separated admin eNames | +| `AAAS_JWT_SECRET` | Signs portal session JWTs | +| `AWARENESS_MAX_ATTEMPTS` | Delivery attempts before dead-lettering (default 8) | +| `AWARENESS_DELIVERY_POLL_MS` | Delivery engine poll interval (default 2000) | +| `AWARENESS_NEO4J_URI` / `_USER` / `_PASSWORD` | Neo4j source for the backfill | +| `PUBLIC_AWARENESS_API_URL` | (portal) AaaS API base URL | + +## Running locally + +```sh +# Create the Postgres database, then: +pnpm --filter awareness-service-api build +pnpm --filter awareness-service-api migration:run +pnpm --filter awareness-service-api backfill # one-time, from Neo4j +pnpm --filter awareness-service-api dev # API (seeds catch-all on launch) +pnpm --filter awareness-portal dev # portal +``` diff --git a/docs/docs/Services/_category_.json b/docs/docs/Services/_category_.json new file mode 100644 index 000000000..a9c2dabb2 --- /dev/null +++ b/docs/docs/Services/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Services", + "position": 6 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f1f7d41a..32f51daab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3263,7 +3263,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3284,7 +3284,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -3975,6 +3975,9 @@ importers: services/awareness-service/api: dependencies: + '@scalar/express-api-reference': + specifier: ^0.9.16 + version: 0.9.16 axios: specifier: ^1.6.7 version: 1.13.6 @@ -9069,6 +9072,30 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@scalar/client-side-rendering@0.1.9': + resolution: {integrity: sha512-Gv3CK0X+BqXYkVJLTQXNM8YgdquhuGV4hKlsM8S9k5GGonj8LBDSAiuU3W48JGX1pY7hRotBGcIIU+1a1X1mcw==} + engines: {node: '>=22'} + + '@scalar/express-api-reference@0.9.16': + resolution: {integrity: sha512-P1zT6f5inPQqnqgkmIjaAW1CEmsNOptW4AYOSeSwkAwK9auknUf2337FTaqY/P8irvoI2fzqcBeUCVv17NgY3Q==} + engines: {node: '>=22'} + + '@scalar/helpers@0.8.0': + resolution: {integrity: sha512-gmOC6VravNB9VDl6wnt/GOj4K/hn48tj5bpW4AM4MhH8Ubil6uu7g1DSoKHwltu8Ks79KEtR6JmOrROi9R7jaQ==} + engines: {node: '>=22'} + + '@scalar/schemas@0.2.0': + resolution: {integrity: sha512-FOpNecNoEKo8SogHEsdWlVRN4Q4PH3dzuOBWMA5tGt1xLZu5iFnPG2X/h6+Z/mOR34c7iW+hiKTqdUZoDgT9CA==} + engines: {node: '>=22'} + + '@scalar/types@0.11.0': + resolution: {integrity: sha512-TGZR8sys1jRlaWxLYfBo8y6D1W4UClYuDmkJ6Hmsb7oNuqokEAAK+AVYIzascVdBmaly0D1fqoqK7CzuGYHgyg==} + engines: {node: '>=22'} + + '@scalar/validation@0.5.0': + resolution: {integrity: sha512-48CS1B0C7im57RQCHhS0GKt+qDLxB34IX8rO91jZj9D+Y/OosQZG3Gy57gJdHqZoD7l0sPUZtF8tVYry3BxRtw==} + engines: {node: '>=20'} + '@sidekickicons/react@0.14.0': resolution: {integrity: sha512-GIRm466DxvdrhC9i9fO6qax+5XTtWtJziqdhu0nJgSlxpU+nG6J2yeUlKZORB7qBby0glyC37XedFRS25zeb0w==} peerDependencies: @@ -15774,6 +15801,7 @@ packages: lucide-svelte@0.561.0: resolution: {integrity: sha512-LxKemRvPNMNBjBa7JIVd4gZzBrTtwG1JA2ohpdM15jpQDcZCFWKyJz8eG1McaD0bq2weufkYSuVWQLclmmdUlw==} + deprecated: Package deprecated. Please use @lucide/svelte instead. peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 @@ -19086,6 +19114,10 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -19526,6 +19558,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -19870,6 +19906,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -19887,10 +19924,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -20551,6 +20590,9 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -27154,6 +27196,31 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scalar/client-side-rendering@0.1.9': + dependencies: + '@scalar/schemas': 0.2.0 + '@scalar/types': 0.11.0 + '@scalar/validation': 0.5.0 + + '@scalar/express-api-reference@0.9.16': + dependencies: + '@scalar/client-side-rendering': 0.1.9 + + '@scalar/helpers@0.8.0': {} + + '@scalar/schemas@0.2.0': + dependencies: + '@scalar/validation': 0.5.0 + + '@scalar/types@0.11.0': + dependencies: + '@scalar/helpers': 0.8.0 + nanoid: 5.1.6 + type-fest: 5.6.0 + zod: 4.4.3 + + '@scalar/validation@0.5.0': {} + '@sidekickicons/react@0.14.0(react@18.3.1)': dependencies: react: 18.3.1 @@ -32472,9 +32539,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0(encoding@0.1.13) + fbjs: 2.0.0 immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32482,9 +32549,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -32935,8 +33002,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -32999,21 +33066,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33056,17 +33108,6 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33106,35 +33147,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -33146,7 +33158,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -33853,7 +33865,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0(encoding@0.1.13): + fbjs@2.0.0: dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -34738,9 +34750,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39088,12 +39100,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 @@ -40754,6 +40766,8 @@ snapshots: tabbable@6.4.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@2.6.1: {} tailwind-merge@3.5.0: {} @@ -41368,6 +41382,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -42712,6 +42730,8 @@ snapshots: zod@3.24.2: {} + zod@4.4.3: {} + zustand@5.0.11(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.27 diff --git a/services/awareness-service/README.md b/services/awareness-service/README.md index 38c7e6486..417a56904 100644 --- a/services/awareness-service/README.md +++ b/services/awareness-service/README.md @@ -45,6 +45,17 @@ pnpm --filter awareness-portal dev Then set `AWARENESS_SERVICE_URL` and `AWARENESS_INGEST_SECRET` for evault-core so it forwards packets here. +## API documentation + +The running API serves an interactive [Scalar](https://github.com/scalar/scalar) +reference and a raw OpenAPI 3.1 document: + +- `GET /docs` — Scalar API reference UI +- `GET /openapi.json` — OpenAPI 3.1 document + +A prose overview lives in the docs site under **Services → Awareness as a +Service**. + ## Backward compatibility On launch AaaS seeds a catch-all subscription for every platform currently in diff --git a/services/awareness-service/api/package.json b/services/awareness-service/api/package.json index 7ea1263bb..d4bb3d6ae 100644 --- a/services/awareness-service/api/package.json +++ b/services/awareness-service/api/package.json @@ -15,6 +15,7 @@ "seed:catchall": "ts-node src/scripts/seed-catchall.ts" }, "dependencies": { + "@scalar/express-api-reference": "^0.9.16", "axios": "^1.6.7", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/services/awareness-service/api/src/index.ts b/services/awareness-service/api/src/index.ts index 884d5216b..7a71799df 100644 --- a/services/awareness-service/api/src/index.ts +++ b/services/awareness-service/api/src/index.ts @@ -1,7 +1,9 @@ import "reflect-metadata"; +import { apiReference } from "@scalar/express-api-reference"; import cors from "cors"; import express from "express"; import { config } from "./config"; +import { openApiDocument } from "./openapi"; import { adminRouter } from "./controllers/AdminController"; import { applicationRouter } from "./controllers/ApplicationController"; import { authRouter } from "./controllers/AuthController"; @@ -25,6 +27,19 @@ async function start(): Promise { res.json({ status: "ok", service: "awareness-service" }); }); + // Raw OpenAPI document + interactive Scalar API reference at /docs. + app.get("/openapi.json", (_req, res) => { + res.json(openApiDocument); + }); + app.use( + "/docs", + apiReference({ + content: openApiDocument, + theme: "purple", + metaData: { title: "Awareness as a Service API" }, + }), + ); + app.use(ingestRouter()); app.use(queryRouter()); app.use(subscriptionRouter()); diff --git a/services/awareness-service/api/src/openapi.ts b/services/awareness-service/api/src/openapi.ts new file mode 100644 index 000000000..dcf8f69a2 --- /dev/null +++ b/services/awareness-service/api/src/openapi.ts @@ -0,0 +1,980 @@ +/** + * OpenAPI 3.1 description of the Awareness as a Service API. Served raw at + * GET /openapi.json and rendered as interactive docs (Scalar) at GET /docs. + */ +export const openApiDocument = { + openapi: "3.1.0", + info: { + title: "Awareness as a Service API", + version: "1.0.0", + description: + "AaaS is the single fanout point for MetaEnvelope awareness " + + "packets. evault-core POSTs every change to `/ingest`; AaaS " + + "persists it, lets approved consumers poll history and register " + + "webhook subscriptions filtered by ontology and eVault, and " + + "delivers webhooks with retry + dead-lettering.\n\n" + + "## Authentication\n" + + "- **Ingest** (`/ingest`): the `x-ingest-secret` header, shared " + + "with evault-core.\n" + + "- **Consumer API** (`/api/packets`, `/api/subscriptions`, " + + "`/api/me/*`): `Authorization: Bearer ` where the token is " + + "either an issued API key (`aaas_...`) or a W3DS portal session " + + "JWT.\n" + + "- **Portal** (`/api/applications/*`): a W3DS portal session JWT.\n" + + "- **Admin** (`/api/admin/*`): a W3DS portal session JWT whose " + + "eName is in `AAAS_ADMIN_ENAMES`.", + }, + servers: [{ url: "/", description: "This AaaS instance" }], + tags: [ + { name: "Ingest", description: "Awareness packet ingestion from evault-core" }, + { name: "Query", description: "Polling the awareness packet history" }, + { name: "Subscriptions", description: "Dynamic webhook subscriptions" }, + { name: "Consumer", description: "Consumer self-service" }, + { name: "Auth", description: "W3DS portal login" }, + { name: "Applications", description: "Access applications" }, + { name: "Admin", description: "Application review and dead-letters" }, + { name: "System", description: "Health and service metadata" }, + ], + components: { + securitySchemes: { + ingestSecret: { + type: "apiKey", + in: "header", + name: "x-ingest-secret", + description: "Shared secret presented by evault-core.", + }, + consumerAuth: { + type: "http", + scheme: "bearer", + description: + "An issued API key (`aaas_...`) or a W3DS portal session JWT.", + }, + portalAuth: { + type: "http", + scheme: "bearer", + description: "A W3DS portal session JWT.", + }, + }, + schemas: { + Error: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + AwarenessPayload: { + type: "object", + description: + "The packet evault-core POSTs to /ingest and the body " + + "delivered to webhook subscribers.", + properties: { + id: { type: "string", description: "MetaEnvelope id" }, + w3id: { + type: "string", + nullable: true, + description: "Owner's W3ID (eName)", + }, + evaultPublicKey: { type: "string", nullable: true }, + data: { + type: "object", + nullable: true, + additionalProperties: true, + }, + schemaId: { + type: "string", + description: "The MetaEnvelope ontology", + }, + operation: { + type: "string", + enum: ["create", "update", "delete"], + }, + requestingPlatform: { + type: "string", + nullable: true, + description: + "Origin platform; used to skip ping-pong delivery. " + + "Never persisted or delivered.", + }, + }, + required: ["id", "schemaId"], + }, + Packet: { + type: "object", + properties: { + id: { type: "string" }, + ontology: { type: "string" }, + evaultPublicKey: { type: "string", nullable: true }, + w3id: { type: "string", nullable: true }, + data: { type: "object", nullable: true, additionalProperties: true }, + operation: { type: "string", enum: ["create", "update", "delete"] }, + receivedAt: { type: "string", format: "date-time" }, + }, + }, + Subscription: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + consumerId: { type: "string", format: "uuid" }, + targetUrl: { type: "string" }, + ontologyFilter: { + type: "array", + items: { type: "string" }, + description: "Empty = all ontologies", + }, + evaultFilter: { + type: "array", + items: { type: "string" }, + description: "Empty = all eVaults", + }, + isCatchAll: { type: "boolean" }, + active: { type: "boolean" }, + secret: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + SubscriptionInput: { + type: "object", + properties: { + targetUrl: { + type: "string", + description: + "Defaults to `/api/webhook`", + }, + ontologyFilter: { type: "array", items: { type: "string" } }, + evaultFilter: { type: "array", items: { type: "string" } }, + secret: { + type: "string", + description: + "If set, deliveries carry an `x-aaas-signature` HMAC header", + }, + }, + }, + Delivery: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + subscriptionId: { type: "string", format: "uuid" }, + packetId: { type: "string" }, + status: { + type: "string", + enum: ["pending", "delivering", "delivered", "failed"], + }, + attempts: { type: "integer" }, + nextAttemptAt: { type: "string", format: "date-time" }, + lastError: { type: "string", nullable: true }, + lastResponseStatus: { type: "integer", nullable: true }, + deliveredAt: { type: "string", format: "date-time", nullable: true }, + }, + }, + DeadLetter: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + deliveryId: { type: "string", format: "uuid" }, + subscriptionId: { type: "string", format: "uuid" }, + packetId: { type: "string" }, + consumerId: { type: "string", format: "uuid" }, + payload: { type: "object", additionalProperties: true }, + targetUrl: { type: "string" }, + totalAttempts: { type: "integer" }, + lastError: { type: "string", nullable: true }, + lastResponseStatus: { type: "integer", nullable: true }, + resolved: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + Consumer: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + ename: { type: "string" }, + name: { type: "string", nullable: true }, + status: { + type: "string", + enum: ["pending", "approved", "rejected", "revoked"], + }, + webhookBaseUrl: { type: "string", nullable: true }, + }, + }, + AccessApplication: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + consumerId: { type: "string", format: "uuid" }, + justification: { type: "string", nullable: true }, + requestedOntologies: { type: "array", items: { type: "string" } }, + status: { + type: "string", + enum: ["pending", "approved", "rejected"], + }, + reviewedByEname: { type: "string", nullable: true }, + reviewNote: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + paths: { + "/health": { + get: { + tags: ["System"], + summary: "Liveness check", + responses: { + "200": { + description: "Service is up", + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: { type: "string" }, + service: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + "/ingest": { + post: { + tags: ["Ingest"], + summary: "Ingest an awareness packet", + description: + "The single sink evault-core POSTs every MetaEnvelope " + + "change to. Upserts the packet and queues a delivery per " + + "matching subscription.", + security: [{ ingestSecret: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AwarenessPayload" }, + }, + }, + }, + responses: { + "200": { + description: "Packet stored and deliveries queued", + content: { + "application/json": { + schema: { + type: "object", + properties: { + ok: { type: "boolean" }, + packetId: { type: "string" }, + deliveriesQueued: { type: "integer" }, + }, + }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/packets": { + get: { + tags: ["Query"], + summary: "Poll awareness packet history", + description: + "Filter the packet history by ontology, eVault and time " + + "range, paged with an opaque cursor.", + security: [{ consumerAuth: [] }], + parameters: [ + { + name: "ontology", + in: "query", + schema: { type: "string" }, + description: "Comma-separated list of ontologies", + }, + { + name: "evault", + in: "query", + schema: { type: "string" }, + description: "Match w3id or evaultPublicKey", + }, + { + name: "from", + in: "query", + schema: { type: "string", format: "date-time" }, + }, + { + name: "to", + in: "query", + schema: { type: "string", format: "date-time" }, + }, + { + name: "limit", + in: "query", + schema: { type: "integer", default: 100, maximum: 500 }, + }, + { + name: "cursor", + in: "query", + schema: { type: "string" }, + description: "nextCursor from a previous response", + }, + ], + responses: { + "200": { + description: "A page of packets", + content: { + "application/json": { + schema: { + type: "object", + properties: { + packets: { + type: "array", + items: { $ref: "#/components/schemas/Packet" }, + }, + hasMore: { type: "boolean" }, + nextCursor: { type: "string", nullable: true }, + }, + }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "403": { $ref: "#/components/responses/Forbidden" }, + }, + }, + }, + "/api/subscriptions": { + get: { + tags: ["Subscriptions"], + summary: "List your subscriptions", + security: [{ consumerAuth: [] }], + responses: { + "200": { + description: "Your subscriptions", + content: { + "application/json": { + schema: { + type: "object", + properties: { + subscriptions: { + type: "array", + items: { + $ref: "#/components/schemas/Subscription", + }, + }, + }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Subscriptions"], + summary: "Register a webhook subscription", + security: [{ consumerAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SubscriptionInput" }, + }, + }, + }, + responses: { + "201": { + description: "Subscription created", + content: { + "application/json": { + schema: { + type: "object", + properties: { + subscription: { + $ref: "#/components/schemas/Subscription", + }, + }, + }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/subscriptions/{id}": { + patch: { + tags: ["Subscriptions"], + summary: "Update a subscription", + security: [{ consumerAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + allOf: [ + { $ref: "#/components/schemas/SubscriptionInput" }, + { + type: "object", + properties: { active: { type: "boolean" } }, + }, + ], + }, + }, + }, + }, + responses: { + "200": { + description: "Updated subscription", + content: { + "application/json": { + schema: { + type: "object", + properties: { + subscription: { + $ref: "#/components/schemas/Subscription", + }, + }, + }, + }, + }, + }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["Subscriptions"], + summary: "Disable a subscription", + description: "Soft delete - sets the subscription inactive.", + security: [{ consumerAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/me": { + get: { + tags: ["Consumer"], + summary: "Your consumer profile", + security: [{ consumerAuth: [] }], + responses: { + "200": { + description: "Consumer profile", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Consumer" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/me/api-keys": { + get: { + tags: ["Consumer"], + summary: "List your API keys", + security: [{ consumerAuth: [] }], + responses: { + "200": { + description: "API key metadata (never the plaintext)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + apiKeys: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + keyPrefix: { type: "string" }, + revoked: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + post: { + tags: ["Consumer"], + summary: "Issue a new API key", + description: + "Returns the plaintext key exactly once - it cannot be " + + "retrieved again.", + security: [{ consumerAuth: [] }], + responses: { + "201": { + description: "New API key", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + keyPrefix: { type: "string" }, + apiKey: { + type: "string", + description: "Plaintext, shown once", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/api/me/api-keys/{id}": { + delete: { + tags: ["Consumer"], + summary: "Revoke an API key", + security: [{ consumerAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/me/deliveries": { + get: { + tags: ["Consumer"], + summary: "Recent webhook deliveries", + security: [{ consumerAuth: [] }], + parameters: [ + { + name: "limit", + in: "query", + schema: { type: "integer", default: 50, maximum: 200 }, + }, + ], + responses: { + "200": { + description: "Recent deliveries across your subscriptions", + content: { + "application/json": { + schema: { + type: "object", + properties: { + deliveries: { + type: "array", + items: { $ref: "#/components/schemas/Delivery" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/api/auth/offer": { + post: { + tags: ["Auth"], + summary: "Start a W3DS login", + description: "Returns a `w3ds://auth` deeplink and a session id.", + responses: { + "200": { + description: "Auth offer", + content: { + "application/json": { + schema: { + type: "object", + properties: { + uri: { type: "string" }, + session: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + "/api/auth": { + post: { + tags: ["Auth"], + summary: "W3DS wallet callback", + description: + "Called by the eID wallet to submit the signature over " + + "the session id.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + w3id: { type: "string" }, + session: { type: "string" }, + signature: { type: "string" }, + }, + required: ["w3id", "session", "signature"], + }, + }, + }, + }, + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/auth/session/{session}": { + get: { + tags: ["Auth"], + summary: "Poll a login session", + description: + "The portal polls this until the wallet has signed in, " + + "then receives the session JWT.", + parameters: [ + { + name: "session", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Session status", + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: { + type: "string", + enum: ["pending", "authenticated"], + }, + token: { + type: "string", + description: "Present once authenticated", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/api/applications/me": { + get: { + tags: ["Applications"], + summary: "Your application status", + security: [{ portalAuth: [] }], + responses: { + "200": { + description: "Your consumer and latest application", + content: { + "application/json": { + schema: { + type: "object", + properties: { + consumer: { + $ref: "#/components/schemas/Consumer", + }, + application: { + $ref: "#/components/schemas/AccessApplication", + }, + }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/applications": { + post: { + tags: ["Applications"], + summary: "Apply for access", + security: [{ portalAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string" }, + contactEmail: { type: "string" }, + webhookBaseUrl: { type: "string" }, + justification: { type: "string" }, + requestedOntologies: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }, + responses: { + "201": { + description: "Application submitted", + content: { + "application/json": { + schema: { + type: "object", + properties: { + consumer: { + $ref: "#/components/schemas/Consumer", + }, + application: { + $ref: "#/components/schemas/AccessApplication", + }, + }, + }, + }, + }, + }, + "409": { + description: "Consumer is already approved", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, + "/api/admin/applications": { + get: { + tags: ["Admin"], + summary: "List access applications", + security: [{ portalAuth: [] }], + parameters: [ + { + name: "status", + in: "query", + schema: { + type: "string", + enum: ["pending", "approved", "rejected", "all"], + default: "pending", + }, + }, + ], + responses: { + "200": { + description: "Applications with consumer details", + content: { + "application/json": { + schema: { + type: "object", + properties: { + applications: { + type: "array", + items: { + $ref: "#/components/schemas/AccessApplication", + }, + }, + }, + }, + }, + }, + }, + "403": { $ref: "#/components/responses/Forbidden" }, + }, + }, + }, + "/api/admin/applications/{id}/approve": { + post: { + tags: ["Admin"], + summary: "Approve an application", + security: [{ portalAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { note: { type: "string" } }, + }, + }, + }, + }, + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "403": { $ref: "#/components/responses/Forbidden" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/admin/applications/{id}/reject": { + post: { + tags: ["Admin"], + summary: "Reject an application", + security: [{ portalAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { note: { type: "string" } }, + }, + }, + }, + }, + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "403": { $ref: "#/components/responses/Forbidden" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/admin/dead-letters": { + get: { + tags: ["Admin"], + summary: "List dead-lettered deliveries", + security: [{ portalAuth: [] }], + parameters: [ + { + name: "resolved", + in: "query", + schema: { type: "boolean", default: false }, + description: "Include already-resolved dead letters", + }, + ], + responses: { + "200": { + description: "Dead letters", + content: { + "application/json": { + schema: { + type: "object", + properties: { + deadLetters: { + type: "array", + items: { + $ref: "#/components/schemas/DeadLetter", + }, + }, + }, + }, + }, + }, + }, + "403": { $ref: "#/components/responses/Forbidden" }, + }, + }, + }, + "/api/admin/dead-letters/{id}/replay": { + post: { + tags: ["Admin"], + summary: "Replay a dead-lettered delivery", + description: + "Re-queues the original delivery and marks the dead " + + "letter resolved.", + security: [{ portalAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "403": { $ref: "#/components/responses/Forbidden" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + }, +} as const; + +// Reusable responses are attached after the fact to keep the paths readable. +(openApiDocument as any).components.responses = { + Ok: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + BadRequest: { + description: "Invalid request", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + Unauthorized: { + description: "Missing or invalid credentials", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + Forbidden: { + description: "Authenticated but not permitted", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + NotFound: { + description: "Resource not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, +}; From 3983db2aae0ebf88ad149c1e7ef2f9ab5a904bd1 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 18:15:00 +0530 Subject: [PATCH 10/24] chore(awareness-service): reuse standard NEO4J env vars for backfill The Neo4j backfill now reads the root .env's NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD - the same connection evault-core uses - instead of duplicate AWARENESS_NEO4J_* vars. Drop the AaaS-specific Neo4j vars from .env.example and the docs. --- .env.example | 7 +++---- docs/docs/Services/Awareness-as-a-Service.md | 2 +- .../awareness-service/api/src/scripts/backfill-neo4j.ts | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index db6914d3d..ebc131d80 100644 --- a/.env.example +++ b/.env.example @@ -147,9 +147,8 @@ AAAS_JWT_SECRET="replace-with-a-strong-secret" # Webhook delivery tuning AWARENESS_MAX_ATTEMPTS=8 AWARENESS_DELIVERY_POLL_MS=2000 -# Neo4j source for the one-time backfill (evault-core's graph) -AWARENESS_NEO4J_URI="bolt://localhost:7687" -AWARENESS_NEO4J_USER="neo4j" -AWARENESS_NEO4J_PASSWORD="your-neo4j-password" +# The one-time Neo4j backfill reuses the standard NEO4J_URI / NEO4J_USER / +# NEO4J_PASSWORD vars at the top of this file - it reads evault-core's graph +# directly, so there are no AaaS-specific Neo4j vars. # Portal -> API base URL PUBLIC_AWARENESS_API_URL="http://localhost:4100" diff --git a/docs/docs/Services/Awareness-as-a-Service.md b/docs/docs/Services/Awareness-as-a-Service.md index 6633bd395..9173926c8 100644 --- a/docs/docs/Services/Awareness-as-a-Service.md +++ b/docs/docs/Services/Awareness-as-a-Service.md @@ -162,7 +162,7 @@ AaaS is designed to be dropped in with **zero receiver-side changes**: | `AAAS_JWT_SECRET` | Signs portal session JWTs | | `AWARENESS_MAX_ATTEMPTS` | Delivery attempts before dead-lettering (default 8) | | `AWARENESS_DELIVERY_POLL_MS` | Delivery engine poll interval (default 2000) | -| `AWARENESS_NEO4J_URI` / `_USER` / `_PASSWORD` | Neo4j source for the backfill | +| `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` | Standard eVault Neo4j vars — reused by the one-time backfill | | `PUBLIC_AWARENESS_API_URL` | (portal) AaaS API base URL | ## Running locally diff --git a/services/awareness-service/api/src/scripts/backfill-neo4j.ts b/services/awareness-service/api/src/scripts/backfill-neo4j.ts index 07ed18fe6..0990204a2 100644 --- a/services/awareness-service/api/src/scripts/backfill-neo4j.ts +++ b/services/awareness-service/api/src/scripts/backfill-neo4j.ts @@ -34,9 +34,11 @@ function deserialize(value: unknown, type: string): unknown { } async function main(): Promise { - const uri = process.env.AWARENESS_NEO4J_URI ?? "bolt://localhost:7687"; - const user = process.env.AWARENESS_NEO4J_USER ?? "neo4j"; - const password = process.env.AWARENESS_NEO4J_PASSWORD ?? "neo4j"; + // Reuse evault-core's own Neo4j connection vars from the root .env - + // AaaS runs on the same node, so it reads the same graph. + const uri = process.env.NEO4J_URI ?? "bolt://localhost:7687"; + const user = process.env.NEO4J_USER ?? "neo4j"; + const password = process.env.NEO4J_PASSWORD ?? "neo4j"; const evaultPublicKey = process.env.EVAULT_PUBLIC_KEY ?? null; const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); From 03b976a22f13552617e764dc363face08c2a0075 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 20:05:00 +0530 Subject: [PATCH 11/24] fix(awareness-service): claim deliveries via query builder returning DataSource.query() with UPDATE ... RETURNING returned a [rows, affectedCount] tuple, so the delivery engine iterated the tuple instead of the rows - each 'delivery' had no id and Repository.update threw 'Empty criteria(s) are not allowed'. Claim the batch with the query builder's update().returning('*') instead, whose .raw is a well-defined rows array, and skip any row missing an id defensively. --- .../api/src/services/DeliveryEngine.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/services/awareness-service/api/src/services/DeliveryEngine.ts b/services/awareness-service/api/src/services/DeliveryEngine.ts index 9ef60412f..edb9575d2 100644 --- a/services/awareness-service/api/src/services/DeliveryEngine.ts +++ b/services/awareness-service/api/src/services/DeliveryEngine.ts @@ -52,23 +52,35 @@ export class DeliveryEngine { /** Atomically move a batch of due deliveries to `delivering`. */ private async claimBatch(): Promise { - const rows = await AppDataSource.query( - `UPDATE deliveries SET status = 'delivering' - WHERE id IN ( - SELECT id FROM deliveries - WHERE status IN ('pending', 'failed') - AND "nextAttemptAt" <= now() - ORDER BY "nextAttemptAt" - LIMIT $1 - FOR UPDATE SKIP LOCKED - ) - RETURNING *`, - [BATCH_SIZE], - ); - return rows as Delivery[]; + // UPDATE ... RETURNING via the query builder so the returned rows are + // exposed as a well-defined `.raw` array. The inner SELECT ... FOR + // UPDATE SKIP LOCKED keeps the claim safe across concurrent ticks. + const result = await AppDataSource.getRepository(Delivery) + .createQueryBuilder() + .update(Delivery) + .set({ status: "delivering" }) + .where( + `id IN ( + SELECT id FROM deliveries + WHERE status IN ('pending', 'failed') + AND "nextAttemptAt" <= now() + ORDER BY "nextAttemptAt" + LIMIT :limit + FOR UPDATE SKIP LOCKED + )`, + { limit: BATCH_SIZE }, + ) + .returning("*") + .execute(); + + return (result.raw ?? []) as Delivery[]; } private async attemptDelivery(delivery: Delivery): Promise { + if (!delivery?.id) { + console.warn("[aaas] skipping delivery row with no id"); + return; + } const subscription = await AppDataSource.getRepository( Subscription, ).findOne({ where: { id: delivery.subscriptionId } }); From 8aee1023c6cbb4055f1f90527e051c33d9144267 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 21:30:00 +0530 Subject: [PATCH 12/24] fix(awareness-service): inline portal API base URL with static env Use $env/static/public so PUBLIC_AWARENESS_API_URL is baked into the portal bundle at build time, instead of $env/dynamic/public which reads process.env at runtime and fell back to localhost under pm2. --- services/awareness-service/portal/src/lib/api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/awareness-service/portal/src/lib/api.ts b/services/awareness-service/portal/src/lib/api.ts index 0b14bda8d..b52ef382b 100644 --- a/services/awareness-service/portal/src/lib/api.ts +++ b/services/awareness-service/portal/src/lib/api.ts @@ -1,8 +1,8 @@ -import { env } from "$env/dynamic/public"; +import { PUBLIC_AWARENESS_API_URL } from "$env/static/public"; -/** Base URL of the AaaS API. Falls back to the default local dev port. */ +/** Base URL of the AaaS API. Inlined at build time from the root .env. */ export const API_BASE = - env.PUBLIC_AWARENESS_API_URL ?? "http://localhost:4100"; + PUBLIC_AWARENESS_API_URL || "http://localhost:4100"; export interface ApiError { error: string; From 2f96ac576747df308a604684e13fa480f7088b27 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 22:40:00 +0530 Subject: [PATCH 13/24] feat(awareness-service): ontology picker, API docs link, admin gating - Subscription form now selects ontologies from the ontology service (https://ontology.w3ds.metastate.foundation/schemas) instead of free text, and takes eVault filters as a tag input. - Dashboard links straight to the interactive API reference, built from the API base URL. - The session JWT now carries an isAdmin claim; the portal hides the Admin nav link from non-admins. --- .../api/src/services/W3dsAuthService.ts | 8 +- .../portal/src/lib/ontology.ts | 21 ++ .../portal/src/lib/session.ts | 26 ++- .../portal/src/routes/+layout.svelte | 7 +- .../portal/src/routes/dashboard/+page.svelte | 189 +++++++++++++++--- 5 files changed, 217 insertions(+), 34 deletions(-) create mode 100644 services/awareness-service/portal/src/lib/ontology.ts diff --git a/services/awareness-service/api/src/services/W3dsAuthService.ts b/services/awareness-service/api/src/services/W3dsAuthService.ts index 045ca0dab..fae97130b 100644 --- a/services/awareness-service/api/src/services/W3dsAuthService.ts +++ b/services/awareness-service/api/src/services/W3dsAuthService.ts @@ -78,7 +78,13 @@ export class W3dsAuthService { } issueToken(ename: string): string { - return jwt.sign({ ename }, config.jwtSecret, { expiresIn: "7d" }); + // isAdmin is embedded so the portal can show/hide admin UI without an + // extra round-trip; the API still re-checks it server-side. + return jwt.sign( + { ename, isAdmin: config.adminEnames.includes(ename) }, + config.jwtSecret, + { expiresIn: "7d" }, + ); } verifyToken(token: string): { ename: string } | null { diff --git a/services/awareness-service/portal/src/lib/ontology.ts b/services/awareness-service/portal/src/lib/ontology.ts new file mode 100644 index 000000000..1426e9d41 --- /dev/null +++ b/services/awareness-service/portal/src/lib/ontology.ts @@ -0,0 +1,21 @@ +/** + * Client for the ontology service, which serves the catalogue of MetaEnvelope + * schemas. Used to make ontologies selectable in the subscription UI. + */ +export const ONTOLOGY_BASE = "https://ontology.w3ds.metastate.foundation"; + +export interface OntologySchema { + /** The schemaId (UUID) — this is what AaaS stores as a packet's ontology. */ + id: string; + title: string; +} + +/** Fetches the full list of available ontologies. */ +export async function fetchSchemas(): Promise { + const res = await fetch(`${ONTOLOGY_BASE}/schemas`); + if (!res.ok) { + throw new Error(`ontology service returned ${res.status}`); + } + const list = (await res.json()) as OntologySchema[]; + return list.map((s) => ({ id: s.id, title: s.title ?? s.id })); +} diff --git a/services/awareness-service/portal/src/lib/session.ts b/services/awareness-service/portal/src/lib/session.ts index e31c4b1e9..c0aeb65f8 100644 --- a/services/awareness-service/portal/src/lib/session.ts +++ b/services/awareness-service/portal/src/lib/session.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import { writable } from "svelte/store"; +import { derived, writable } from "svelte/store"; const STORAGE_KEY = "aaas_session_token"; @@ -15,6 +15,30 @@ if (browser) { }); } +export interface SessionClaims { + ename: string; + isAdmin: boolean; +} + +/** Decodes the (unverified) JWT payload — fine for UI gating only. */ +function decodeClaims(token: string | null): SessionClaims | null { + if (!token) return null; + try { + const payload = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")), + ); + return { + ename: String(payload.ename ?? ""), + isAdmin: payload.isAdmin === true, + }; + } catch { + return null; + } +} + +/** Claims of the logged-in user, or null. */ +export const session = derived(sessionToken, ($t) => decodeClaims($t)); + export function logout(): void { sessionToken.set(null); } diff --git a/services/awareness-service/portal/src/routes/+layout.svelte b/services/awareness-service/portal/src/routes/+layout.svelte index 04b5cd6b3..338f5daf4 100644 --- a/services/awareness-service/portal/src/routes/+layout.svelte +++ b/services/awareness-service/portal/src/routes/+layout.svelte @@ -1,9 +1,10 @@
@@ -16,7 +17,9 @@ {#if loggedIn} Dashboard Apply - Admin + {#if isAdmin} + Admin + {/if} +
+ {#if schemas.length === 0} +

+ Ontology catalogue unavailable. +

+ {/if} + {#if selectedOntologies.length} +
+ {#each selectedOntologies as id (id)} + + {ontologyTitle(id)} + + + {/each} +
+ {/if} + + + +
+ + eVaults (none = all) + + { + if (e.key === "Enter") { + e.preventDefault(); + addEvaultTag(); + } + }} + placeholder="Type an eVault (w3id or public key) and press Enter" + class="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm" + /> + {#if evaultTags.length} +
+ {#each evaultTags as tag (tag)} + + {tag} + + + {/each} +
+ {/if} +
+ {:else}
-

+

Scan with your eID wallet to sign in.

-
+ +
{#if polling} -

Waiting for signature…

+

Waiting for signature…

{/if}
{/if} diff --git a/services/awareness-service/portal/src/routes/admin/+page.svelte b/services/awareness-service/portal/src/routes/admin/+page.svelte index 1a40ebc56..12fe71fba 100644 --- a/services/awareness-service/portal/src/routes/admin/+page.svelte +++ b/services/awareness-service/portal/src/routes/admin/+page.svelte @@ -9,11 +9,9 @@ id: string; status: string; justification: string | null; - requestedOntologies: string[]; consumer?: { ename: string; name: string | null; - contactEmail: string | null; webhookBaseUrl: string | null; }; } @@ -66,44 +64,50 @@
{#if notAdmin} -

+

Your eName is not in the admin allowlist.

{:else if error} -

{error}

+

{error}

{:else if loading} -

Loading…

+

Loading…

{:else}
    {#each applications as app (app.id)} -
  • -
    +
  • +
    -

    +

    {app.consumer?.name ?? app.consumer?.ename}

    {app.consumer?.ename}

    -

    - {app.consumer?.contactEmail ?? "no email"} · - {app.consumer?.webhookBaseUrl ?? "no webhook URL"} -

    + {#if app.consumer?.webhookBaseUrl} + + {app.consumer.webhookBaseUrl} + + {/if}
    {#if app.justification} -

    {app.justification}

    - {/if} - {#if app.requestedOntologies.length} -

    - Requested: {app.requestedOntologies.join(", ")} -

    +

    {app.justification}

    {/if}
  • {:else} -
  • No pending applications.
  • +
  • No pending applications.
  • {/each}
{/if} diff --git a/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte b/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte index 624d79317..78d87b664 100644 --- a/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte +++ b/services/awareness-service/portal/src/routes/admin/dead-letters/+page.svelte @@ -63,39 +63,39 @@
-

Dead-lettered deliveries

-

+

Dead-lettered deliveries

+

Deliveries that exhausted every retry attempt. Replay re-queues them.

{#if notAdmin} -

+

Your eName is not in the admin allowlist.

{:else if error} -

{error}

+

{error}

{:else if loading} -

Loading…

+

Loading…

{:else}
    {#each deadLetters as dl (dl.id)} -
  • -
    +
  • +
    -

    {dl.targetUrl}

    +

    {dl.targetUrl}

    packet {dl.packetId} · {dl.totalAttempts} attempts · status {dl.lastResponseStatus ?? "n/a"}

    {#if dl.lastError} -

    {dl.lastError}

    +

    {dl.lastError}

    {/if}
    {#if dl.resolved} - resolved + resolved {:else}
  • {:else} -
  • No dead letters.
  • +
  • No dead letters.
  • {/each}
{/if} diff --git a/services/awareness-service/portal/src/routes/apply/+page.svelte b/services/awareness-service/portal/src/routes/apply/+page.svelte index ca2d4c37f..aeaed3dd9 100644 --- a/services/awareness-service/portal/src/routes/apply/+page.svelte +++ b/services/awareness-service/portal/src/routes/apply/+page.svelte @@ -6,10 +6,8 @@ import { sessionToken } from "$lib/session"; let name = $state(""); - let contactEmail = $state(""); - let webhookBaseUrl = $state(""); - let justification = $state(""); - let requestedOntologies = $state(""); + let websiteUrl = $state(""); + let description = $state(""); let error = $state(null); let submitting = $state(false); let existingStatus = $state(null); @@ -38,16 +36,7 @@ await api("/api/applications", { method: "POST", token: get(sessionToken), - body: { - name, - contactEmail, - webhookBaseUrl, - justification, - requestedOntologies: requestedOntologies - .split(",") - .map((o) => o.trim()) - .filter(Boolean), - }, + body: { name, websiteUrl, description }, }); goto("/dashboard"); } catch (e) { @@ -59,67 +48,50 @@
-

Apply for access

-

- Submit your platform details. A whitelisted admin will review the - request before you can poll packets or register webhooks. +

Apply for access

+

+ Tell us your platform name, website and what it does. A whitelisted + admin will review the request before you can poll packets or register + webhooks.

{#if existingStatus} -

+

You already have an application — current status: {existingStatus}. Submitting again updates it.

{/if} {#if error} -

{error}

+

{error}

{/if}
- -
{#if newKey} -

+

Copy this key now — it is shown only once: {newKey}

{/if} -
    +
      {#each apiKeys as key (key.id)}
    • - {key.keyPrefix}… + {key.keyPrefix}… {#if key.revoked} - revoked + revoked {:else}
    • {:else} -
    • No keys yet.
    • +
    • No keys yet.
    • {/each}
-
-

Webhook subscriptions

+
+

Webhook subscriptions

- + Ontologies (none = all)
{#if schemas.length === 0} -

+

Ontology catalogue unavailable.

{/if} @@ -304,12 +304,12 @@
{#each selectedOntologies as id (id)} {ontologyTitle(id)}
-
    +
      {#each subscriptions as sub (sub.id)}
    • - {sub.targetUrl} + {sub.targetUrl}
    • {:else} -
    • No subscriptions.
    • +
    • No subscriptions.
    • {/each}
-
-

Recent deliveries

-
    +
    +

    Recent deliveries

    +
      {#each deliveries as d (d.id)}
    • - {d.packetId} + {d.packetId} @@ -408,7 +408,7 @@
    • {:else} -
    • No deliveries yet.
    • +
    • No deliveries yet.
    • {/each}
    From 5ac5399c03bc963512ab55256e962ccbfa3d9839 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 23:58:00 +0530 Subject: [PATCH 15/24] docs(awareness-service): scope API reference to consumer endpoints The OpenAPI document / Scalar reference now only describes endpoints a consuming platform integrates against - packet polling, webhook subscriptions and consumer self-service. The ingest endpoint, W3DS portal auth, access applications and admin routes are no longer exposed in the docs. --- services/awareness-service/api/src/openapi.ts | 490 +----------------- 1 file changed, 19 insertions(+), 471 deletions(-) diff --git a/services/awareness-service/api/src/openapi.ts b/services/awareness-service/api/src/openapi.ts index dcf8f69a2..0db5d5087 100644 --- a/services/awareness-service/api/src/openapi.ts +++ b/services/awareness-service/api/src/openapi.ts @@ -1,6 +1,12 @@ /** - * OpenAPI 3.1 description of the Awareness as a Service API. Served raw at - * GET /openapi.json and rendered as interactive docs (Scalar) at GET /docs. + * OpenAPI 3.1 description of the *consumer-facing* Awareness as a Service API. + * Served raw at GET /openapi.json and rendered as interactive docs (Scalar) at + * GET /docs. + * + * Only endpoints a consuming platform integrates against are documented here - + * polling packet history and managing webhook subscriptions / API keys. The + * ingest endpoint (evault-core only), the W3DS portal login, access + * applications and the admin routes are intentionally omitted. */ export const openApiDocument = { openapi: "3.1.0", @@ -8,51 +14,27 @@ export const openApiDocument = { title: "Awareness as a Service API", version: "1.0.0", description: - "AaaS is the single fanout point for MetaEnvelope awareness " + - "packets. evault-core POSTs every change to `/ingest`; AaaS " + - "persists it, lets approved consumers poll history and register " + - "webhook subscriptions filtered by ontology and eVault, and " + - "delivers webhooks with retry + dead-lettering.\n\n" + + "Consume MetaEnvelope awareness packets: poll the packet history " + + "by ontology, eVault and time range, and register webhook " + + "subscriptions filtered by ontology and eVault.\n\n" + "## Authentication\n" + - "- **Ingest** (`/ingest`): the `x-ingest-secret` header, shared " + - "with evault-core.\n" + - "- **Consumer API** (`/api/packets`, `/api/subscriptions`, " + - "`/api/me/*`): `Authorization: Bearer ` where the token is " + - "either an issued API key (`aaas_...`) or a W3DS portal session " + - "JWT.\n" + - "- **Portal** (`/api/applications/*`): a W3DS portal session JWT.\n" + - "- **Admin** (`/api/admin/*`): a W3DS portal session JWT whose " + - "eName is in `AAAS_ADMIN_ENAMES`.", + "All endpoints use `Authorization: Bearer `, where the " + + "token is an API key (`aaas_…`) issued to your approved consumer " + + "from the portal dashboard.", }, servers: [{ url: "/", description: "This AaaS instance" }], tags: [ - { name: "Ingest", description: "Awareness packet ingestion from evault-core" }, { name: "Query", description: "Polling the awareness packet history" }, { name: "Subscriptions", description: "Dynamic webhook subscriptions" }, { name: "Consumer", description: "Consumer self-service" }, - { name: "Auth", description: "W3DS portal login" }, - { name: "Applications", description: "Access applications" }, - { name: "Admin", description: "Application review and dead-letters" }, { name: "System", description: "Health and service metadata" }, ], components: { securitySchemes: { - ingestSecret: { - type: "apiKey", - in: "header", - name: "x-ingest-secret", - description: "Shared secret presented by evault-core.", - }, consumerAuth: { type: "http", scheme: "bearer", - description: - "An issued API key (`aaas_...`) or a W3DS portal session JWT.", - }, - portalAuth: { - type: "http", - scheme: "bearer", - description: "A W3DS portal session JWT.", + description: "An API key (`aaas_…`) issued to your consumer.", }, }, schemas: { @@ -61,49 +43,18 @@ export const openApiDocument = { properties: { error: { type: "string" } }, required: ["error"], }, - AwarenessPayload: { + Packet: { type: "object", - description: - "The packet evault-core POSTs to /ingest and the body " + - "delivered to webhook subscribers.", + description: "A stored awareness packet.", properties: { id: { type: "string", description: "MetaEnvelope id" }, + ontology: { type: "string" }, + evaultPublicKey: { type: "string", nullable: true }, w3id: { type: "string", nullable: true, description: "Owner's W3ID (eName)", }, - evaultPublicKey: { type: "string", nullable: true }, - data: { - type: "object", - nullable: true, - additionalProperties: true, - }, - schemaId: { - type: "string", - description: "The MetaEnvelope ontology", - }, - operation: { - type: "string", - enum: ["create", "update", "delete"], - }, - requestingPlatform: { - type: "string", - nullable: true, - description: - "Origin platform; used to skip ping-pong delivery. " + - "Never persisted or delivered.", - }, - }, - required: ["id", "schemaId"], - }, - Packet: { - type: "object", - properties: { - id: { type: "string" }, - ontology: { type: "string" }, - evaultPublicKey: { type: "string", nullable: true }, - w3id: { type: "string", nullable: true }, data: { type: "object", nullable: true, additionalProperties: true }, operation: { type: "string", enum: ["create", "update", "delete"] }, receivedAt: { type: "string", format: "date-time" }, @@ -165,23 +116,6 @@ export const openApiDocument = { deliveredAt: { type: "string", format: "date-time", nullable: true }, }, }, - DeadLetter: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - deliveryId: { type: "string", format: "uuid" }, - subscriptionId: { type: "string", format: "uuid" }, - packetId: { type: "string" }, - consumerId: { type: "string", format: "uuid" }, - payload: { type: "object", additionalProperties: true }, - targetUrl: { type: "string" }, - totalAttempts: { type: "integer" }, - lastError: { type: "string", nullable: true }, - lastResponseStatus: { type: "integer", nullable: true }, - resolved: { type: "boolean" }, - createdAt: { type: "string", format: "date-time" }, - }, - }, Consumer: { type: "object", properties: { @@ -195,22 +129,6 @@ export const openApiDocument = { webhookBaseUrl: { type: "string", nullable: true }, }, }, - AccessApplication: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - consumerId: { type: "string", format: "uuid" }, - justification: { type: "string", nullable: true }, - requestedOntologies: { type: "array", items: { type: "string" } }, - status: { - type: "string", - enum: ["pending", "approved", "rejected"], - }, - reviewedByEname: { type: "string", nullable: true }, - reviewNote: { type: "string", nullable: true }, - createdAt: { type: "string", format: "date-time" }, - }, - }, }, }, paths: { @@ -236,44 +154,6 @@ export const openApiDocument = { }, }, }, - "/ingest": { - post: { - tags: ["Ingest"], - summary: "Ingest an awareness packet", - description: - "The single sink evault-core POSTs every MetaEnvelope " + - "change to. Upserts the packet and queues a delivery per " + - "matching subscription.", - security: [{ ingestSecret: [] }], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AwarenessPayload" }, - }, - }, - }, - responses: { - "200": { - description: "Packet stored and deliveries queued", - content: { - "application/json": { - schema: { - type: "object", - properties: { - ok: { type: "boolean" }, - packetId: { type: "string" }, - deliveriesQueued: { type: "integer" }, - }, - }, - }, - }, - }, - "400": { $ref: "#/components/responses/BadRequest" }, - "401": { $ref: "#/components/responses/Unauthorized" }, - }, - }, - }, "/api/packets": { get: { tags: ["Query"], @@ -597,338 +477,6 @@ export const openApiDocument = { }, }, }, - "/api/auth/offer": { - post: { - tags: ["Auth"], - summary: "Start a W3DS login", - description: "Returns a `w3ds://auth` deeplink and a session id.", - responses: { - "200": { - description: "Auth offer", - content: { - "application/json": { - schema: { - type: "object", - properties: { - uri: { type: "string" }, - session: { type: "string" }, - }, - }, - }, - }, - }, - }, - }, - }, - "/api/auth": { - post: { - tags: ["Auth"], - summary: "W3DS wallet callback", - description: - "Called by the eID wallet to submit the signature over " + - "the session id.", - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - properties: { - w3id: { type: "string" }, - session: { type: "string" }, - signature: { type: "string" }, - }, - required: ["w3id", "session", "signature"], - }, - }, - }, - }, - responses: { - "200": { $ref: "#/components/responses/Ok" }, - "401": { $ref: "#/components/responses/Unauthorized" }, - }, - }, - }, - "/api/auth/session/{session}": { - get: { - tags: ["Auth"], - summary: "Poll a login session", - description: - "The portal polls this until the wallet has signed in, " + - "then receives the session JWT.", - parameters: [ - { - name: "session", - in: "path", - required: true, - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Session status", - content: { - "application/json": { - schema: { - type: "object", - properties: { - status: { - type: "string", - enum: ["pending", "authenticated"], - }, - token: { - type: "string", - description: "Present once authenticated", - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/api/applications/me": { - get: { - tags: ["Applications"], - summary: "Your application status", - security: [{ portalAuth: [] }], - responses: { - "200": { - description: "Your consumer and latest application", - content: { - "application/json": { - schema: { - type: "object", - properties: { - consumer: { - $ref: "#/components/schemas/Consumer", - }, - application: { - $ref: "#/components/schemas/AccessApplication", - }, - }, - }, - }, - }, - }, - "401": { $ref: "#/components/responses/Unauthorized" }, - }, - }, - }, - "/api/applications": { - post: { - tags: ["Applications"], - summary: "Apply for access", - security: [{ portalAuth: [] }], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { type: "string" }, - contactEmail: { type: "string" }, - webhookBaseUrl: { type: "string" }, - justification: { type: "string" }, - requestedOntologies: { - type: "array", - items: { type: "string" }, - }, - }, - }, - }, - }, - }, - responses: { - "201": { - description: "Application submitted", - content: { - "application/json": { - schema: { - type: "object", - properties: { - consumer: { - $ref: "#/components/schemas/Consumer", - }, - application: { - $ref: "#/components/schemas/AccessApplication", - }, - }, - }, - }, - }, - }, - "409": { - description: "Consumer is already approved", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - }, - }, - }, - }, - }, - }, - "/api/admin/applications": { - get: { - tags: ["Admin"], - summary: "List access applications", - security: [{ portalAuth: [] }], - parameters: [ - { - name: "status", - in: "query", - schema: { - type: "string", - enum: ["pending", "approved", "rejected", "all"], - default: "pending", - }, - }, - ], - responses: { - "200": { - description: "Applications with consumer details", - content: { - "application/json": { - schema: { - type: "object", - properties: { - applications: { - type: "array", - items: { - $ref: "#/components/schemas/AccessApplication", - }, - }, - }, - }, - }, - }, - }, - "403": { $ref: "#/components/responses/Forbidden" }, - }, - }, - }, - "/api/admin/applications/{id}/approve": { - post: { - tags: ["Admin"], - summary: "Approve an application", - security: [{ portalAuth: [] }], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - }, - ], - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - properties: { note: { type: "string" } }, - }, - }, - }, - }, - responses: { - "200": { $ref: "#/components/responses/Ok" }, - "403": { $ref: "#/components/responses/Forbidden" }, - "404": { $ref: "#/components/responses/NotFound" }, - }, - }, - }, - "/api/admin/applications/{id}/reject": { - post: { - tags: ["Admin"], - summary: "Reject an application", - security: [{ portalAuth: [] }], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - }, - ], - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - properties: { note: { type: "string" } }, - }, - }, - }, - }, - responses: { - "200": { $ref: "#/components/responses/Ok" }, - "403": { $ref: "#/components/responses/Forbidden" }, - "404": { $ref: "#/components/responses/NotFound" }, - }, - }, - }, - "/api/admin/dead-letters": { - get: { - tags: ["Admin"], - summary: "List dead-lettered deliveries", - security: [{ portalAuth: [] }], - parameters: [ - { - name: "resolved", - in: "query", - schema: { type: "boolean", default: false }, - description: "Include already-resolved dead letters", - }, - ], - responses: { - "200": { - description: "Dead letters", - content: { - "application/json": { - schema: { - type: "object", - properties: { - deadLetters: { - type: "array", - items: { - $ref: "#/components/schemas/DeadLetter", - }, - }, - }, - }, - }, - }, - }, - "403": { $ref: "#/components/responses/Forbidden" }, - }, - }, - }, - "/api/admin/dead-letters/{id}/replay": { - post: { - tags: ["Admin"], - summary: "Replay a dead-lettered delivery", - description: - "Re-queues the original delivery and marks the dead " + - "letter resolved.", - security: [{ portalAuth: [] }], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - }, - ], - responses: { - "200": { $ref: "#/components/responses/Ok" }, - "403": { $ref: "#/components/responses/Forbidden" }, - "404": { $ref: "#/components/responses/NotFound" }, - }, - }, - }, }, } as const; From 7e725d1910703f7e18b04012769e9306dc93b84e Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 23:30:00 +0530 Subject: [PATCH 16/24] feat(webhook-inlet-test): print every received webhook Fix the broken express setup and make it a tiny inlet that logs the method, path, headers and body of any request on any path, and replies 200 - handy for eyeballing AaaS webhook deliveries. --- services/webhook-inlet-test/index.js | 20 ++++++++++++++++++++ services/webhook-inlet-test/package.json | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 services/webhook-inlet-test/index.js create mode 100644 services/webhook-inlet-test/package.json diff --git a/services/webhook-inlet-test/index.js b/services/webhook-inlet-test/index.js new file mode 100644 index 000000000..6a90c12e6 --- /dev/null +++ b/services/webhook-inlet-test/index.js @@ -0,0 +1,20 @@ +const express = require("express"); + +const app = express(); +const PORT = process.env.PORT || 1234; + +// Accept any content type; non-JSON bodies still parse as best they can. +app.use(express.json({ limit: "10mb", type: () => true })); + +// Log and 200 everything, whatever path or method it arrives on. +app.all(/.*/, (req, res) => { + console.log("\n─────────────────────────────────────────────"); + console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl}`); + console.log("headers:", req.headers); + console.log("body:", JSON.stringify(req.body, null, 2)); + res.status(200).json({ ok: true }); +}); + +app.listen(PORT, () => { + console.log(`[webhook-inlet-test] listening on :${PORT}`); +}); diff --git a/services/webhook-inlet-test/package.json b/services/webhook-inlet-test/package.json new file mode 100644 index 000000000..824d3fbad --- /dev/null +++ b/services/webhook-inlet-test/package.json @@ -0,0 +1,17 @@ +{ + "name": "webhook-inlet-test", + "version": "1.0.0", + "description": "Tiny Express server that prints every webhook it receives", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.25.0", + "dependencies": { + "express": "^5.2.1" + } +} From d5f2e98a1db764edb4ef0c1c42f2bd39baa399c3 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 10:30:00 +0530 Subject: [PATCH 17/24] test(evault-core): update webhook spec for AaaS ingest The graphql-server spec asserted evault-core POSTed webhooks to platform /api/webhook endpoints. evault-core now forwards a single packet to AWARENESS_SERVICE_URL/ingest, so the spec intercepts /ingest instead, drops the obsolete /platforms mock, and no longer waits on the removed setTimeout fanout delay. --- .../src/core/protocol/graphql-server.spec.ts | 142 ++++++++---------- 1 file changed, 60 insertions(+), 82 deletions(-) diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.spec.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.spec.ts index 86dff5a08..539bbdae2 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.spec.ts @@ -12,15 +12,17 @@ import { import { getSharedTestKeyPair } from "../../test-utils/shared-test-keys"; // Store original axios functions before any spying happens -const originalAxiosGet = axios.get; const originalAxiosPost = axios.post; -describe("GraphQLServer Webhook Payload W3ID", () => { +// evault-core forwards every awareness packet to AaaS at +// AWARENESS_SERVICE_URL/ingest; point it somewhere the spy can intercept. +process.env.AWARENESS_SERVICE_URL = "http://localhost:9999"; + +describe("GraphQLServer Awareness Ingest Payload W3ID", () => { let server: E2ETestServer; let evault1: ProvisionedEVault; let evault2: ProvisionedEVault; const evaultW3ID = "evault-w3id-123"; - let axiosGetSpy: any; let axiosPostSpy: any; beforeAll(async () => { @@ -31,54 +33,32 @@ describe("GraphQLServer Webhook Payload W3ID", () => { afterAll(async () => { await teardownE2ETestServer(server); - // Restore original implementations - if (axiosGetSpy) { - axiosGetSpy.mockRestore(); - } if (axiosPostSpy) { axiosPostSpy.mockRestore(); } }); beforeEach(() => { - // Restore any existing spies first - if (axiosGetSpy) { - axiosGetSpy.mockRestore(); - } if (axiosPostSpy) { axiosPostSpy.mockRestore(); } - + vi.clearAllMocks(); - - // Mock axios.get for platforms endpoint only - axiosGetSpy = vi.spyOn(axios, "get").mockImplementation((...args: any[]) => { - const url = args[0]; - if (typeof url === "string" && url.includes("/platforms")) { - return Promise.resolve({ - data: ["http://localhost:9999"], // Mock platform URL - }) as any; - } - // For other GET requests, call through to original with all arguments preserved - return (originalAxiosGet as any).apply(axios, args); - }); - // Spy on axios.post to capture webhook payloads + // Spy on axios.post to capture the awareness ingest payload. axiosPostSpy = vi.spyOn(axios, "post").mockImplementation((url: string | any, data?: any, config?: any) => { - // If it's a webhook call, capture it and return success - // Note: axios.post(url, data, config) - data is the second parameter - if (typeof url === "string" && url.includes("/api/webhook")) { - // Log for debugging - console.log("Webhook intercepted:", { url, data }); - return Promise.resolve({ status: 200, data: {} }) as any; + // If it's the AaaS ingest call, capture it and return success. + if (typeof url === "string" && url.includes("/ingest")) { + console.log("Ingest intercepted:", { url, data }); + return Promise.resolve({ status: 200, data: { ok: true } }) as any; } - // For GraphQL and other requests, call through to original (stored before spying) + // For GraphQL and other requests, call through to original. return originalAxiosPost.call(axios, url, data, config); }); }); - describe("storeMetaEnvelope webhook payload", () => { - it("should include X-ENAME in webhook payload", async () => { + describe("storeMetaEnvelope ingest payload", () => { + it("should include X-ENAME in the ingest payload", async () => { const testData = { field: "value", test: "store-test" }; const testOntology = "WebhookTestOntology"; @@ -104,33 +84,33 @@ describe("GraphQLServer Webhook Payload W3ID", () => { "X-ENAME": evault1.w3id, }); - // Wait for the setTimeout delay (3 seconds) in the actual code - await new Promise(resolve => setTimeout(resolve, 3500)); + // notifyAwareness is fire-and-forget; give it a moment to run. + await new Promise(resolve => setTimeout(resolve, 1000)); - // Verify axios.post was called (webhook delivery) + // Verify axios.post was called (awareness ingest) expect(axios.post).toHaveBeenCalled(); - - // Get the webhook payload from the axios.post call - const webhookCalls = (axios.post as any).mock.calls; - const webhookCall = webhookCalls.find((call: any[]) => - typeof call[0] === "string" && call[0].includes("/api/webhook") + + // Get the ingest payload from the axios.post call + const ingestCalls = (axios.post as any).mock.calls; + const ingestCall = ingestCalls.find((call: any[]) => + typeof call[0] === "string" && call[0].includes("/ingest") ); - expect(webhookCall).toBeDefined(); - const webhookPayload = webhookCall[1]; // Second argument is the payload + expect(ingestCall).toBeDefined(); + const ingestPayload = ingestCall[1]; // Second argument is the payload - console.log("Webhook payload:", JSON.stringify(webhookPayload, null, 2)); + console.log("Ingest payload:", JSON.stringify(ingestPayload, null, 2)); console.log("Expected w3id:", evault1.w3id); - // Verify the webhook payload contains the user's W3ID, not the eVault's W3ID - expect(webhookPayload).toBeDefined(); - expect(webhookPayload.w3id).toBe(evault1.w3id); - expect(webhookPayload.w3id).not.toBe(evaultW3ID); - expect(webhookPayload.data).toEqual(testData); - expect(webhookPayload.schemaId).toBe(testOntology); + // Verify the payload contains the user's W3ID, not the eVault's W3ID + expect(ingestPayload).toBeDefined(); + expect(ingestPayload.w3id).toBe(evault1.w3id); + expect(ingestPayload.w3id).not.toBe(evaultW3ID); + expect(ingestPayload.data).toEqual(testData); + expect(ingestPayload.schemaId).toBe(testOntology); }); - it("should use different W3IDs for different users in webhook payloads", async () => { + it("should use different W3IDs for different users in ingest payloads", async () => { const testData1 = { user: "1", data: "test1" }; const testData2 = { user: "2", data: "test2" }; const testOntology = "MultiUserWebhookTest"; @@ -168,21 +148,21 @@ describe("GraphQLServer Webhook Payload W3ID", () => { "X-ENAME": evault2.w3id, }); - // Wait for setTimeout delays - await new Promise(resolve => setTimeout(resolve, 3500)); + // Give the fire-and-forget ingest calls a moment to run. + await new Promise(resolve => setTimeout(resolve, 1000)); - // Get all webhook calls - const webhookCalls = (axios.post as any).mock.calls.filter((call: any[]) => - typeof call[0] === "string" && call[0].includes("/api/webhook") + // Get all ingest calls + const ingestCalls = (axios.post as any).mock.calls.filter((call: any[]) => + typeof call[0] === "string" && call[0].includes("/ingest") ); - expect(webhookCalls.length).toBeGreaterThanOrEqual(2); + expect(ingestCalls.length).toBeGreaterThanOrEqual(2); // Find payloads by their data - const payload1 = webhookCalls.find((call: any[]) => + const payload1 = ingestCalls.find((call: any[]) => call[1]?.data?.user === "1" )?.[1]; - const payload2 = webhookCalls.find((call: any[]) => + const payload2 = ingestCalls.find((call: any[]) => call[1]?.data?.user === "2" )?.[1]; @@ -194,8 +174,8 @@ describe("GraphQLServer Webhook Payload W3ID", () => { }); }); - describe("updateMetaEnvelopeById webhook payload", () => { - it("should include user's W3ID (eName) in webhook payload, not eVault's W3ID", async () => { + describe("updateMetaEnvelopeById ingest payload", () => { + it("should include user's W3ID (eName) in the ingest payload, not eVault's W3ID", async () => { const testData = { field: "updated-value", test: "update-test" }; const testOntology = "UpdateWebhookTestOntology"; @@ -223,7 +203,7 @@ describe("GraphQLServer Webhook Payload W3ID", () => { const envelopeId = createResult.storeMetaEnvelope.metaEnvelope.id; - // Clear previous webhook calls + // Clear previous ingest calls (axios.post as any).mockClear(); // Now update the envelope @@ -238,8 +218,7 @@ describe("GraphQLServer Webhook Payload W3ID", () => { } `; - // Create a valid Bearer token for authentication - // The platform field should be a valid URL for webhook delivery + // Create a valid Bearer token for authentication. const { privateKey } = await getSharedTestKeyPair(); const testToken = await new jose.SignJWT({ platform: "http://localhost:3000" }) .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) @@ -259,28 +238,27 @@ describe("GraphQLServer Webhook Payload W3ID", () => { "Authorization": `Bearer ${testToken}`, }); - // Wait a bit for webhook delivery (update doesn't have setTimeout delay) - await new Promise(resolve => setTimeout(resolve, 500)); + // Give the fire-and-forget ingest call a moment to run. + await new Promise(resolve => setTimeout(resolve, 1000)); - // Verify axios.post was called (webhook delivery) + // Verify axios.post was called (awareness ingest) expect(axios.post).toHaveBeenCalled(); - - // Get the webhook payload - const webhookCalls = (axios.post as any).mock.calls.filter((call: any[]) => - typeof call[0] === "string" && call[0].includes("/api/webhook") + + // Get the ingest payload + const ingestCalls = (axios.post as any).mock.calls.filter((call: any[]) => + typeof call[0] === "string" && call[0].includes("/ingest") ); - expect(webhookCalls.length).toBeGreaterThan(0); - const webhookPayload = webhookCalls[0][1]; + expect(ingestCalls.length).toBeGreaterThan(0); + const ingestPayload = ingestCalls[0][1]; - // Verify the webhook payload contains the user's W3ID, not the eVault's W3ID - expect(webhookPayload).toBeDefined(); - expect(webhookPayload.w3id).toBe(evault1.w3id); - expect(webhookPayload.w3id).not.toBe(evaultW3ID); - expect(webhookPayload.id).toBe(envelopeId); - expect(webhookPayload.data).toEqual(testData); - expect(webhookPayload.schemaId).toBe(testOntology); + // Verify the payload contains the user's W3ID, not the eVault's W3ID + expect(ingestPayload).toBeDefined(); + expect(ingestPayload.w3id).toBe(evault1.w3id); + expect(ingestPayload.w3id).not.toBe(evaultW3ID); + expect(ingestPayload.id).toBe(envelopeId); + expect(ingestPayload.data).toEqual(testData); + expect(ingestPayload.schemaId).toBe(testOntology); }); }); }); - From 935a85e9f36f74c57f6459ae3d9b1c23ba923173 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 15:56:02 +0530 Subject: [PATCH 18/24] fix: tests --- pnpm-lock.yaml | 213 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f51daab..1a918a27f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4134,6 +4134,12 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@24.12.0)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0) + services/webhook-inlet-test: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + tests: dependencies: '@ngneat/falso': @@ -11198,6 +11204,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-globals@6.0.0: resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} @@ -11733,6 +11743,10 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bonjour-service@1.3.0: resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} @@ -12289,10 +12303,18 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -12302,6 +12324,10 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -13757,6 +13783,10 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -13930,6 +13960,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-cache-dir@4.0.0: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} @@ -14099,6 +14133,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -14630,6 +14668,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -14932,6 +14974,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -15934,6 +15979,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memfs@4.56.11: resolution: {integrity: sha512-/GodtwVeKVIHZKLUSr2ZdOxKBC5hHki4JNCU22DoCGPEHr5o2PD5U721zvESKyWwCfTfavFl9WZYgA13OAYK0g==} peerDependencies: @@ -15953,6 +16002,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -16370,6 +16423,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -16828,6 +16885,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -17840,6 +17900,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -18326,6 +18390,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rtlcss@4.3.0: resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==} engines: {node: '>=12.0.0'} @@ -18474,6 +18542,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -18488,6 +18560,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -19566,6 +19642,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -30412,6 +30492,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-globals@6.0.0: dependencies: acorn: 7.4.1 @@ -31053,6 +31138,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bonjour-service@1.3.0: dependencies: fast-deep-equal: 3.1.3 @@ -31685,14 +31784,20 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.6.0: {} cookie@0.7.2: {} @@ -33721,6 +33826,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext@1.7.0: dependencies: type: 2.7.3 @@ -33933,6 +34071,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-cache-dir@4.0.0: dependencies: common-path-prefix: 3.0.0 @@ -34178,6 +34327,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@11.3.4: @@ -34912,6 +35063,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + icss-utils@5.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -35152,6 +35307,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -36830,6 +36987,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + memfs@4.56.11(tslib@2.8.1): dependencies: '@jsonjoy.com/fs-core': 4.56.11(tslib@2.8.1) @@ -36871,6 +37030,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -37446,6 +37607,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} neo4j-driver-bolt-connection@5.28.3: @@ -37983,6 +38146,8 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -39064,6 +39229,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -39739,6 +39911,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rtlcss@4.3.0: dependencies: escalade: 3.2.0 @@ -39914,6 +40096,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -39949,6 +40147,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -41391,6 +41598,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + type@2.7.3: {} typed-array-buffer@1.0.3: From 85c45b5b6cca6b8346135c1359e88d34293110c7 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 11:15:00 +0530 Subject: [PATCH 19/24] fix(awareness-service): add svelte-qrcode type declaration svelte-qrcode ships no types, so svelte-check (run by pnpm check in CI) failed on the portal with 'Cannot find module svelte-qrcode'. Add a module declaration shim, mirroring the one in platforms/enotary. --- .../portal/src/lib/types/svelte-qrcode.d.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 services/awareness-service/portal/src/lib/types/svelte-qrcode.d.ts diff --git a/services/awareness-service/portal/src/lib/types/svelte-qrcode.d.ts b/services/awareness-service/portal/src/lib/types/svelte-qrcode.d.ts new file mode 100644 index 000000000..530d51fc3 --- /dev/null +++ b/services/awareness-service/portal/src/lib/types/svelte-qrcode.d.ts @@ -0,0 +1,10 @@ +declare module "svelte-qrcode" { + import type { Component } from "svelte"; + + export declare const QrCode: Component<{ + value: string; + size?: number; + }>; + + export default QrCode; +} From 2cd99a442601d4460cf1f5a5524d3e0483ac9216 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 12:30:00 +0530 Subject: [PATCH 20/24] fix(awareness-service): dedupe backfill batch by packet id The Neo4j graph can hold multiple MetaEnvelope nodes with the same id, which made a batch upsert touch the same ON CONFLICT target twice - Postgres rejects that with 'cannot affect row a second time'. Collapse duplicate ids within each batch before upserting. --- .../api/src/scripts/backfill-neo4j.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/services/awareness-service/api/src/scripts/backfill-neo4j.ts b/services/awareness-service/api/src/scripts/backfill-neo4j.ts index 0990204a2..43e81b0cd 100644 --- a/services/awareness-service/api/src/scripts/backfill-neo4j.ts +++ b/services/awareness-service/api/src/scripts/backfill-neo4j.ts @@ -96,9 +96,17 @@ async function main(): Promise { }); }); - if (packets.length > 0) { - await packetRepo.upsert(packets, ["id"]); - total += packets.length; + // The graph can hold several MetaEnvelope nodes with the same id + // (e.g. shared across eNames). Postgres rejects an upsert that + // touches the same conflict target twice in one statement, so + // collapse duplicates within the batch first (last write wins). + const deduped = Array.from( + new Map(packets.map((p) => [p.id, p])).values(), + ); + + if (deduped.length > 0) { + await packetRepo.upsert(deduped, ["id"]); + total += deduped.length; } console.log(`[backfill] processed ${total} packets...`); skip += BATCH; From 929a167582b6967cedde19c4af4ab40925f963e2 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 13:45:00 +0530 Subject: [PATCH 21/24] feat(awareness-service): report total and page count in packet query GET /api/packets now also returns count (packets in this page), total (all packets matching the filter), pageSize and totalPages alongside the existing cursor pagination. The total is computed with a filter-only COUNT, independent of the cursor. --- .../api/src/controllers/QueryController.ts | 49 +++++++++++++------ services/awareness-service/api/src/openapi.ts | 18 +++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/services/awareness-service/api/src/controllers/QueryController.ts b/services/awareness-service/api/src/controllers/QueryController.ts index cd4c5e70b..b9ecc2610 100644 --- a/services/awareness-service/api/src/controllers/QueryController.ts +++ b/services/awareness-service/api/src/controllers/QueryController.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { Brackets } from "typeorm"; +import { Brackets, type SelectQueryBuilder } from "typeorm"; import { AppDataSource } from "../database/data-source"; import { Packet } from "../database/entities/Packet"; import { consumerAuth } from "../middleware/consumerAuth"; @@ -11,7 +11,8 @@ const MAX_LIMIT = 500; /** * GET /api/packets - polling query API. Approved consumers filter the awareness * packet history by ontology, eVault and time range, paged with an opaque - * (receivedAt, id) cursor. + * (receivedAt, id) cursor. The response also reports the total match count and + * page count for the current filter. */ export function queryRouter(): Router { const router = Router(); @@ -41,24 +42,36 @@ export function queryRouter(): Router { .json({ error: "from/to must be ISO timestamps" }); } - const qb = AppDataSource.getRepository(Packet) - .createQueryBuilder("p") + // Applies the ontology / eVault / time-range filters (everything + // except the pagination cursor) to a fresh query builder. + const withFilters = (): SelectQueryBuilder => { + const qb = AppDataSource.getRepository(Packet).createQueryBuilder( + "p", + ); + if (ontologies.length > 0) { + qb.andWhere("p.ontology IN (:...ontologies)", { ontologies }); + } + if (evault) { + qb.andWhere( + "(p.w3id = :evault OR p.evaultPublicKey = :evault)", + { evault }, + ); + } + if (from) qb.andWhere("p.receivedAt >= :from", { from }); + if (to) qb.andWhere("p.receivedAt <= :to", { to }); + return qb; + }; + + // Total number of packets matching the filter (cursor-independent). + const total = await withFilters().getCount(); + + // The current page: filters + cursor, ordered, one extra row to detect + // whether more pages follow. + const qb = withFilters() .orderBy("p.receivedAt", "ASC") .addOrderBy("p.id", "ASC") .take(limit + 1); - if (ontologies.length > 0) { - qb.andWhere("p.ontology IN (:...ontologies)", { ontologies }); - } - if (evault) { - qb.andWhere( - "(p.w3id = :evault OR p.evaultPublicKey = :evault)", - { evault }, - ); - } - if (from) qb.andWhere("p.receivedAt >= :from", { from }); - if (to) qb.andWhere("p.receivedAt <= :to", { to }); - if (typeof req.query.cursor === "string" && req.query.cursor) { const cursor = decodeCursor(req.query.cursor); if (!cursor) { @@ -83,6 +96,10 @@ export function queryRouter(): Router { return res.json({ packets, + count: packets.length, + total, + pageSize: limit, + totalPages: Math.ceil(total / limit), hasMore, nextCursor: hasMore && last diff --git a/services/awareness-service/api/src/openapi.ts b/services/awareness-service/api/src/openapi.ts index 0db5d5087..0955e3685 100644 --- a/services/awareness-service/api/src/openapi.ts +++ b/services/awareness-service/api/src/openapi.ts @@ -209,6 +209,24 @@ export const openApiDocument = { type: "array", items: { $ref: "#/components/schemas/Packet" }, }, + count: { + type: "integer", + description: "Packets in this page", + }, + total: { + type: "integer", + description: + "Total packets matching the filter", + }, + pageSize: { + type: "integer", + description: "Effective limit", + }, + totalPages: { + type: "integer", + description: + "ceil(total / pageSize)", + }, hasMore: { type: "boolean" }, nextCursor: { type: "string", nullable: true }, }, From 4548f75fb394a7ad472bf82f591ae34a349531ba Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 15:10:00 +0530 Subject: [PATCH 22/24] fix(awareness-service): handle transient DB outages quietly in delivery engine When Postgres restarts or is in recovery (57P03 and friends), the delivery tick threw every 2s and dumped a full stack trace each time. Detect transient DB-unavailable errors and log a single concise warning per outage; the engine resumes automatically once the database is back. --- .../api/src/services/DeliveryEngine.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/services/awareness-service/api/src/services/DeliveryEngine.ts b/services/awareness-service/api/src/services/DeliveryEngine.ts index edb9575d2..cd566b594 100644 --- a/services/awareness-service/api/src/services/DeliveryEngine.ts +++ b/services/awareness-service/api/src/services/DeliveryEngine.ts @@ -11,6 +11,24 @@ import type { AwarenessPayload } from "../types"; const BATCH_SIZE = 50; +/** + * True for transient "Postgres is not ready" errors - server restarting, in + * recovery, or unreachable. These resolve on their own once the DB is back. + */ +function isDbUnavailable(err: any): boolean { + const code = err?.code ?? err?.driverError?.code; + return ( + code === "57P03" || // cannot connect now / in recovery + code === "57P01" || // admin shutdown + code === "08006" || // connection failure + code === "08001" || // unable to establish connection + code === "08003" || // connection does not exist + code === "ECONNREFUSED" || + code === "ETIMEDOUT" || + code === "ENOTFOUND" + ); +} + /** * Background worker that drains the deliveries queue. Each tick atomically * claims a batch of due deliveries (FOR UPDATE SKIP LOCKED so concurrent ticks @@ -21,6 +39,7 @@ const BATCH_SIZE = 50; export class DeliveryEngine { private timer?: NodeJS.Timeout; private running = false; + private dbDown = false; start(): void { this.timer = setInterval(() => { @@ -43,8 +62,21 @@ export class DeliveryEngine { for (const delivery of claimed) { await this.attemptDelivery(delivery); } + this.dbDown = false; } catch (err) { - console.error("[aaas] delivery tick failed:", err); + if (isDbUnavailable(err)) { + // Postgres is restarting / in recovery - transient. Log once + // per outage instead of dumping a stack trace every tick. + if (!this.dbDown) { + this.dbDown = true; + console.warn( + "[aaas] database unavailable, pausing delivery until it recovers", + ); + } + } else { + this.dbDown = false; + console.error("[aaas] delivery tick failed:", err); + } } finally { this.running = false; } From c70ea59fde846aeb277f7c0d3e18e7a79273a97d Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 16:25:00 +0530 Subject: [PATCH 23/24] fix(awareness-service): trim dead-letter list payload, center login QR - GET /api/admin/dead-letters returns metadata only; the full webhook payload column is omitted so the list stays small. - Center the QR code within its white plate on the login screen. --- .../api/src/controllers/AdminController.ts | 15 +++++++++++++++ .../portal/src/routes/+page.svelte | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/services/awareness-service/api/src/controllers/AdminController.ts b/services/awareness-service/api/src/controllers/AdminController.ts index ac2017b4b..936215e88 100644 --- a/services/awareness-service/api/src/controllers/AdminController.ts +++ b/services/awareness-service/api/src/controllers/AdminController.ts @@ -75,9 +75,24 @@ export function adminRouter(): Router { router.get("/api/admin/dead-letters", async (req, res) => { const includeResolved = req.query.resolved === "true"; + // Metadata only - the `payload` column holds the full webhook body and + // would bloat the list. Fetch it on replay if ever needed. const deadLetters = await AppDataSource.getRepository( DeadLetter, ).find({ + select: [ + "id", + "deliveryId", + "subscriptionId", + "packetId", + "consumerId", + "targetUrl", + "totalAttempts", + "lastError", + "lastResponseStatus", + "resolved", + "createdAt", + ], where: includeResolved ? {} : { resolved: false }, order: { createdAt: "DESC" }, take: 200, diff --git a/services/awareness-service/portal/src/routes/+page.svelte b/services/awareness-service/portal/src/routes/+page.svelte index 26719ecff..ba1f931f3 100644 --- a/services/awareness-service/portal/src/routes/+page.svelte +++ b/services/awareness-service/portal/src/routes/+page.svelte @@ -71,7 +71,7 @@ Scan with your eID wallet to sign in.

    -
    +
    {#if polling} From a60cffdb7cfdd91f861c1b4856c512cdfab1086b Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 17:30:00 +0530 Subject: [PATCH 24/24] chore(awareness-service): bump axios floor to a non-vulnerable release The declared range was ^1.6.7 - a stale floor that permits axios versions with known CVEs. The lockfile already resolved to 1.13.6 (past the relevant fixes); raise the declared floor to ^1.13.6 so the range itself can no longer resolve to a vulnerable version. --- pnpm-lock.yaml | 89 +++++++++++++++++---- services/awareness-service/api/package.json | 2 +- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a918a27f..3e35217d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3263,7 +3263,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3284,7 +3284,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -3979,7 +3979,7 @@ importers: specifier: ^0.9.16 version: 0.9.16 axios: - specifier: ^1.6.7 + specifier: ^1.13.6 version: 1.13.6 cors: specifier: ^2.8.5 @@ -32644,9 +32644,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0 + fbjs: 2.0.0(encoding@0.1.13) immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32654,9 +32654,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -33107,8 +33107,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -33171,6 +33171,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3(supports-color@5.5.0) + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33213,6 +33228,17 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33252,7 +33278,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -33281,6 +33307,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): dependencies: aria-query: 5.3.2 @@ -34003,7 +34058,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0: + fbjs@2.0.0(encoding@0.1.13): dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -34901,9 +34956,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39272,12 +39327,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 diff --git a/services/awareness-service/api/package.json b/services/awareness-service/api/package.json index d4bb3d6ae..3a7c8fb0d 100644 --- a/services/awareness-service/api/package.json +++ b/services/awareness-service/api/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@scalar/express-api-reference": "^0.9.16", - "axios": "^1.6.7", + "axios": "^1.13.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2",