From 7c691edd56265e260a6de764c4d7a1ba4e5e6ad4 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 01/14] chore: ignore local .env backups --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 49c0bc5b2..c6c340ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules # Local env files .env +.env.bak* .env.local .env.development.local .env.test.local From ee627b22b8645511342eba3bca847329ee66373c Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 02/14] feat(ontology): publish access domains as a Domain schema --- services/ontology/schemas/domain.json | 129 ++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 services/ontology/schemas/domain.json diff --git a/services/ontology/schemas/domain.json b/services/ontology/schemas/domain.json new file mode 100644 index 000000000..9d2977924 --- /dev/null +++ b/services/ontology/schemas/domain.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "d4fb2883-dd78-47a2-b5fa-e2df1af268c5", + "title": "Domain", + "domain": "governance", + "type": "object", + "description": "An area of data a platform can be granted access to. Every other schema declares the domain it belongs to via its `domain` field, so granting a domain is what decides which record types a platform may touch. The permitted values are the `oneOf` list under `id` — this schema is the published list, not a copy of it.", + "properties": { + "id": { + "type": "string", + "description": "Stable domain id, used in every schema’s `domain` field and in access grants.", + "oneOf": [ + { + "const": "identity", + "title": "Identity", + "description": "Who someone is: profiles, credentials and the documents that bind an identity to a person." + }, + { + "const": "communication", + "title": "Communication", + "description": "Direct and group conversation between people." + }, + { + "const": "social", + "title": "Social", + "description": "Public posting, feeds and things shared with an audience." + }, + { + "const": "community", + "title": "Community", + "description": "Groups, membership and shared community activity." + }, + { + "const": "governance", + "title": "Governance", + "description": "Decision-making: charters, voting, mandates and permissions." + }, + { + "const": "finance", + "title": "Finance", + "description": "Money, accounts, ledgers and currencies." + }, + { + "const": "work", + "title": "Work", + "description": "Organisations, projects and professional activity." + }, + { + "const": "productivity", + "title": "Productivity", + "description": "Tasks, notes, bookmarks and scheduling." + }, + { + "const": "storage", + "title": "Files & documents", + "description": "Stored files and the documents built on them." + }, + { + "const": "reputation", + "title": "Reputation", + "description": "References, endorsements and trust signals." + }, + { + "const": "health", + "title": "Health", + "description": "Health, care and wellbeing records." + }, + { + "const": "education", + "title": "Education", + "description": "Learning, courses, qualifications and training." + }, + { + "const": "mobility", + "title": "Mobility", + "description": "Travel, transport and logistics." + }, + { + "const": "energy", + "title": "Energy & utilities", + "description": "Energy use, metering and utility services." + }, + { + "const": "commerce", + "title": "Commerce", + "description": "Buying, selling, listings and orders." + }, + { + "const": "legal", + "title": "Legal", + "description": "Contracts, signatures, compliance and legal process." + }, + { + "const": "property", + "title": "Property & housing", + "description": "Homes, tenancy, land and the built environment." + }, + { + "const": "agriculture", + "title": "Food & agriculture", + "description": "Farming, food production and supply." + }, + { + "const": "media", + "title": "Media & culture", + "description": "Publishing, broadcast, arts and cultural works." + }, + { + "const": "public", + "title": "Public services", + "description": "Government, civic administration and public sector services." + } + ] + }, + "label": { + "type": "string", + "description": "Human-readable name, as shown to reviewers and users." + }, + "description": { + "type": "string", + "description": "What the domain covers." + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false +} From 4429d65ec9e25ed242951c4ddfb894707a6802f0 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 03/14] feat(ontology): tag every schema with the domain it belongs to --- services/ontology/schemas/accessGrant.json | 1 + services/ontology/schemas/account.json | 86 +++-- .../ontology/schemas/binding-document.json | 364 +++++++++++------- services/ontology/schemas/bookmark.json | 78 ++-- .../schemas/calendarAvailability.json | 1 + services/ontology/schemas/calendarEvent.json | 1 + .../ontology/schemas/charterSignature.json | 117 +++--- services/ontology/schemas/chat.json | 105 ++--- .../ontology/schemas/communityActivity.json | 117 ++++-- services/ontology/schemas/company.json | 34 +- .../ontology/schemas/companyProjectLink.json | 46 ++- .../ontology/schemas/contributorCapacity.json | 35 +- services/ontology/schemas/currency.json | 91 ++--- services/ontology/schemas/file.json | 150 ++++---- services/ontology/schemas/fileSignature.json | 109 +++--- services/ontology/schemas/groupManifest.json | 119 +++--- services/ontology/schemas/ledger.json | 109 +++--- services/ontology/schemas/membership.json | 82 +++- services/ontology/schemas/message.json | 131 ++++--- services/ontology/schemas/poll.json | 127 +++--- .../ontology/schemas/professionalProfile.json | 346 +++++++++-------- services/ontology/schemas/project.json | 39 +- services/ontology/schemas/reference.json | 126 +++--- .../ontology/schemas/socialMediaPost.json | 181 ++++----- services/ontology/schemas/task.json | 268 ++++++++++--- services/ontology/schemas/taskAttachment.json | 35 +- services/ontology/schemas/taskNote.json | 35 +- services/ontology/schemas/taskReference.json | 35 +- services/ontology/schemas/user.json | 252 ++++++------ services/ontology/schemas/vote.json | 255 +++++++----- 30 files changed, 2088 insertions(+), 1387 deletions(-) diff --git a/services/ontology/schemas/accessGrant.json b/services/ontology/schemas/accessGrant.json index b1fb7a9d7..2b62872e3 100644 --- a/services/ontology/schemas/accessGrant.json +++ b/services/ontology/schemas/accessGrant.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "15d24c04-a4f3-4e45-a00e-0123926fbc87", "title": "AccessGrant", + "domain": "governance", "type": "object", "properties": { "isReference": { diff --git a/services/ontology/schemas/account.json b/services/ontology/schemas/account.json index 33ddc17dc..f422a3fd8 100644 --- a/services/ontology/schemas/account.json +++ b/services/ontology/schemas/account.json @@ -1,40 +1,52 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "6fda64db-fd14-4fa2-bd38-77d2e5e6136d", - "title": "Account", - "type": "object", - "properties": { - "accountId": { - "type": "string", - "description": "Account identifier matching accountId in ledger MetaEnvelopes" - }, - "accountEname": { - "type": "string", - "description": "Global eName of the account holder (user or group)" - }, - "accountType": { - "type": "string", - "enum": ["user", "group"], - "description": "Type of account holder" - }, - "currencyEname": { - "type": "string", - "description": "Global eName of the currency" - }, - "currencyName": { - "type": "string", - "description": "Display name of the currency" - }, - "balance": { - "type": "number", - "description": "Current account balance" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the account was first active" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "6fda64db-fd14-4fa2-bd38-77d2e5e6136d", + "title": "Account", + "domain": "finance", + "type": "object", + "properties": { + "accountId": { + "type": "string", + "description": "Account identifier matching accountId in ledger MetaEnvelopes" }, - "required": ["accountId", "accountEname", "accountType", "currencyEname", "currencyName", "balance", "createdAt"], - "additionalProperties": false + "accountEname": { + "type": "string", + "description": "Global eName of the account holder (user or group)" + }, + "accountType": { + "type": "string", + "enum": [ + "user", + "group" + ], + "description": "Type of account holder" + }, + "currencyEname": { + "type": "string", + "description": "Global eName of the currency" + }, + "currencyName": { + "type": "string", + "description": "Display name of the currency" + }, + "balance": { + "type": "number", + "description": "Current account balance" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the account was first active" + } + }, + "required": [ + "accountId", + "accountEname", + "accountType", + "currencyEname", + "currencyName", + "balance", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json index 81995edf2..56f6e16d8 100644 --- a/services/ontology/schemas/binding-document.json +++ b/services/ontology/schemas/binding-document.json @@ -1,163 +1,235 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "b1d0a8c3-4e5f-6789-0abc-def012345678", - "title": "Binding Document", - "type": "object", - "properties": { - "subject": { - "type": "string", - "description": "eName of the subject (pre-fixed with @)" + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b1d0a8c3-4e5f-6789-0abc-def012345678", + "title": "Binding Document", + "domain": "identity", + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "eName of the subject (pre-fixed with @)" + }, + "type": { + "type": "string", + "enum": [ + "id_document", + "photograph", + "social_connection", + "self" + ], + "description": "The type of binding document" + }, + "data": { + "type": "object", + "description": "Format dependent payload for the binding document" + }, + "signatures": { + "type": "array", + "description": "Array of signatures from the user and counterparties", + "items": { + "$ref": "#/definitions/Signature" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "id_document" + } }, - "type": { - "type": "string", - "enum": ["id_document", "photograph", "social_connection", "self"], - "description": "The type of binding document" + "required": [ + "type" + ] + }, + "then": { + "properties": { + "data": { + "$ref": "#/definitions/IdDocumentData" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "photograph" + } }, - "data": { - "type": "object", - "description": "Format dependent payload for the binding document" + "required": [ + "type" + ] + }, + "then": { + "properties": { + "data": { + "$ref": "#/definitions/PhotographData" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "social_connection" + } }, - "signatures": { - "type": "array", - "description": "Array of signatures from the user and counterparties", - "items": { - "$ref": "#/definitions/Signature" - } + "required": [ + "type" + ] + }, + "then": { + "properties": { + "data": { + "$ref": "#/definitions/SocialConnectionData" + } } + } }, - "allOf": [ - { - "if": { - "properties": { "type": { "const": "id_document" } }, - "required": ["type"] - }, - "then": { - "properties": { "data": { "$ref": "#/definitions/IdDocumentData" } } - } + { + "if": { + "properties": { + "type": { + "const": "self" + } }, - { - "if": { - "properties": { "type": { "const": "photograph" } }, - "required": ["type"] - }, - "then": { - "properties": { "data": { "$ref": "#/definitions/PhotographData" } } - } + "required": [ + "type" + ] + }, + "then": { + "properties": { + "data": { + "$ref": "#/definitions/SelfData" + } + } + } + } + ], + "definitions": { + "IdDocumentData": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "description": "Vendor name for the ID document verification" }, - { - "if": { - "properties": { "type": { "const": "social_connection" } }, - "required": ["type"] - }, - "then": { - "properties": { "data": { "$ref": "#/definitions/SocialConnectionData" } } - } + "reference": { + "type": "string", + "description": "Reference ID from the vendor" }, - { - "if": { - "properties": { "type": { "const": "self" } }, - "required": ["type"] - }, - "then": { - "properties": { "data": { "$ref": "#/definitions/SelfData" } } - } + "name": { + "type": "string", + "description": "Name verified against the ID document" } - ], - "definitions": { - "IdDocumentData": { - "type": "object", - "properties": { - "vendor": { - "type": "string", - "description": "Vendor name for the ID document verification" - }, - "reference": { - "type": "string", - "description": "Reference ID from the vendor" - }, - "name": { - "type": "string", - "description": "Name verified against the ID document" - } - }, - "required": ["vendor", "reference", "name"], - "additionalProperties": false + }, + "required": [ + "vendor", + "reference", + "name" + ], + "additionalProperties": false + }, + "PhotographData": { + "type": "object", + "properties": { + "photoBlob": { + "type": "string", + "description": "Base64 encoded photo blob" + } + }, + "required": [ + "photoBlob" + ], + "additionalProperties": false + }, + "SocialConnectionData": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "social_connection", + "description": "Discriminant for social connection data" }, - "PhotographData": { - "type": "object", - "properties": { - "photoBlob": { - "type": "string", - "description": "Base64 encoded photo blob" - } - }, - "required": ["photoBlob"], - "additionalProperties": false + "name": { + "type": "string", + "description": "Name of the social connection" }, - "SocialConnectionData": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "social_connection", - "description": "Discriminant for social connection data" - }, - "name": { - "type": "string", - "description": "Name of the social connection" - }, - "parties": { - "type": "array", - "items": { "type": "string", "pattern": "^@[^\\s]+$" }, - "minItems": 2, - "maxItems": 2, - "description": "eNames of both participants" - }, - "relation_description": { - "type": "string", - "description": "Arbitrary text describing the relationship" - } - }, - "required": ["kind", "name", "parties", "relation_description"], - "additionalProperties": false + "parties": { + "type": "array", + "items": { + "type": "string", + "pattern": "^@[^\\s]+$" + }, + "minItems": 2, + "maxItems": 2, + "description": "eNames of both participants" }, - "SelfData": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "const": "self", - "description": "Discriminant for self data" - }, - "name": { - "type": "string", - "description": "Self-declared name" - } - }, - "required": ["kind", "name"], - "additionalProperties": false + "relation_description": { + "type": "string", + "description": "Arbitrary text describing the relationship" + } + }, + "required": [ + "kind", + "name", + "parties", + "relation_description" + ], + "additionalProperties": false + }, + "SelfData": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "self", + "description": "Discriminant for self data" }, - "Signature": { - "type": "object", - "properties": { - "signer": { - "type": "string", - "description": "eName or keyID of who signed it" - }, - "signature": { - "type": "string", - "description": "Cryptographic signature" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "When the signature was created" - } - }, - "required": ["signer", "signature", "timestamp"], - "additionalProperties": false + "name": { + "type": "string", + "description": "Self-declared name" } + }, + "required": [ + "kind", + "name" + ], + "additionalProperties": false }, - "required": ["subject", "type", "data", "signatures"], - "additionalProperties": false + "Signature": { + "type": "object", + "properties": { + "signer": { + "type": "string", + "description": "eName or keyID of who signed it" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When the signature was created" + } + }, + "required": [ + "signer", + "signature", + "timestamp" + ], + "additionalProperties": false + } + }, + "required": [ + "subject", + "type", + "data", + "signatures" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/bookmark.json b/services/ontology/schemas/bookmark.json index 3788b0814..02a7dd9d7 100644 --- a/services/ontology/schemas/bookmark.json +++ b/services/ontology/schemas/bookmark.json @@ -1,39 +1,45 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440005", - "title": "Bookmark", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the bookmark" - }, - "userId": { - "type": "string", - "format": "uuid", - "description": "The ID of the user who created the bookmark" - }, - "postId": { - "type": "string", - "format": "uuid", - "description": "The ID of the post being bookmarked" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the bookmark was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the bookmark was last updated" - }, - "isArchived": { - "type": "boolean", - "description": "Whether the bookmark is archived" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440005", + "title": "Bookmark", + "domain": "productivity", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the bookmark" }, - "required": ["id", "userId", "postId", "createdAt"], - "additionalProperties": false + "userId": { + "type": "string", + "format": "uuid", + "description": "The ID of the user who created the bookmark" + }, + "postId": { + "type": "string", + "format": "uuid", + "description": "The ID of the post being bookmarked" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the bookmark was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the bookmark was last updated" + }, + "isArchived": { + "type": "boolean", + "description": "Whether the bookmark is archived" + } + }, + "required": [ + "id", + "userId", + "postId", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/calendarAvailability.json b/services/ontology/schemas/calendarAvailability.json index af22ddc09..057f02ed9 100644 --- a/services/ontology/schemas/calendarAvailability.json +++ b/services/ontology/schemas/calendarAvailability.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "a92c3675-dbc8-44f6-b24e-520eeadb8864", "title": "CalendarAvailability", + "domain": "productivity", "type": "object", "properties": { "availabilityId": { diff --git a/services/ontology/schemas/calendarEvent.json b/services/ontology/schemas/calendarEvent.json index f9f3b62a4..256143711 100644 --- a/services/ontology/schemas/calendarEvent.json +++ b/services/ontology/schemas/calendarEvent.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "880e8400-e29b-41d4-a716-446655440099", "title": "CalendarEvent", + "domain": "productivity", "type": "object", "properties": { "title": { diff --git a/services/ontology/schemas/charterSignature.json b/services/ontology/schemas/charterSignature.json index 1eaa03bcf..a44be172b 100644 --- a/services/ontology/schemas/charterSignature.json +++ b/services/ontology/schemas/charterSignature.json @@ -1,60 +1,61 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "1d83fada-581d-49b0-b6f5-1fe0766da34f", - "title": "Charter Signature", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the charter signature" - }, - "group": { - "type": "string", - "format": "uuid", - "description": "ID of the group whose charter is being signed" - }, - "user": { - "type": "string", - "format": "uuid", - "description": "ID of the user who signed the charter" - }, - "charterHash": { - "type": "string", - "description": "Hash of the charter content to track versions and prevent replay attacks" - }, - "signature": { - "type": "string", - "description": "Cryptographic signature proving the user's agreement to the charter" - }, - "publicKey": { - "type": "string", - "description": "User's public key for signature verification" - }, - "message": { - "type": "string", - "description": "Original message that was signed (usually contains charter details)" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the signature was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the signature was last updated" - } - }, - "required": [ - "id", - "group", - "user", - "charterHash", - "signature", - "publicKey", - "message", - "createdAt" - ], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "1d83fada-581d-49b0-b6f5-1fe0766da34f", + "title": "Charter Signature", + "domain": "governance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the charter signature" + }, + "group": { + "type": "string", + "format": "uuid", + "description": "ID of the group whose charter is being signed" + }, + "user": { + "type": "string", + "format": "uuid", + "description": "ID of the user who signed the charter" + }, + "charterHash": { + "type": "string", + "description": "Hash of the charter content to track versions and prevent replay attacks" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature proving the user's agreement to the charter" + }, + "publicKey": { + "type": "string", + "description": "User's public key for signature verification" + }, + "message": { + "type": "string", + "description": "Original message that was signed (usually contains charter details)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was last updated" + } + }, + "required": [ + "id", + "group", + "user", + "charterHash", + "signature", + "publicKey", + "message", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/chat.json b/services/ontology/schemas/chat.json index 5979d89f4..33e8dfcce 100644 --- a/services/ontology/schemas/chat.json +++ b/services/ontology/schemas/chat.json @@ -1,51 +1,60 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440003", - "title": "Chat", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the chat" - }, - "name": { - "type": "string", - "description": "The name of the chat (for group chats)" - }, - "type": { - "type": "string", - "enum": ["direct", "group"], - "description": "The type of chat (direct message or group chat)" - }, - "participantIds": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "description": "Array of user IDs participating in the chat" - }, - "lastMessageId": { - "type": "string", - "format": "uuid", - "description": "ID of the most recent message in the chat" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the chat was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the chat was last updated" - }, - "isArchived": { - "type": "boolean", - "description": "Whether the chat is archived" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "title": "Chat", + "domain": "communication", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the chat" }, - "required": ["id", "type", "participantIds", "createdAt"], - "additionalProperties": false + "name": { + "type": "string", + "description": "The name of the chat (for group chats)" + }, + "type": { + "type": "string", + "enum": [ + "direct", + "group" + ], + "description": "The type of chat (direct message or group chat)" + }, + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Array of user IDs participating in the chat" + }, + "lastMessageId": { + "type": "string", + "format": "uuid", + "description": "ID of the most recent message in the chat" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the chat was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the chat was last updated" + }, + "isArchived": { + "type": "boolean", + "description": "Whether the chat is archived" + } + }, + "required": [ + "id", + "type", + "participantIds", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/communityActivity.json b/services/ontology/schemas/communityActivity.json index 8ac23f98d..01719f13e 100644 --- a/services/ontology/schemas/communityActivity.json +++ b/services/ontology/schemas/communityActivity.json @@ -2,29 +2,61 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "c0117a17-1b2c-4d3e-8f4a-5b6c7d8e9f01", "title": "CommunityActivity", + "domain": "community", "type": "object", "description": "A generic, domain-level 'a platform posted something to a community that members see and may respond to'. NOT a presentation container: activityType follows W3C ActivityStreams and carries the meaning; the renderer decides the look. Prefer a more specific ontology when one fits (CalendarEvent, Poll). CommunityActivity is the general case AND the fallback a renderer uses for any envelope it doesn't specifically know but that carries summary + responseOptions. Responses are recorded as separate Relation envelopes (predicate 'respond'), authored by the responder.", "properties": { - "id": { "type": "string" }, - "chatId": { "type": "string", "description": "The chat this activity surfaces in" }, + "id": { + "type": "string" + }, + "chatId": { + "type": "string", + "description": "The chat this activity surfaces in" + }, "activityType": { "type": "string", - "enum": ["announce", "question", "offer", "invite", "acknowledge"], + "enum": [ + "announce", + "question", + "offer", + "invite", + "acknowledge" + ], "description": "W3C ActivityStreams verb — the semantics of the activity" }, - "summary": { "type": "string", "description": "Human-visible headline (domain field, not 'title')" }, - "description": { "type": "string" }, + "summary": { + "type": "string", + "description": "Human-visible headline (domain field, not 'title')" + }, + "description": { + "type": "string" + }, "relatedSubject": { "type": "object", "description": "Optional pointer to the domain object this activity is about", "properties": { - "id": { "type": "string" }, - "ontologyId": { "type": "string" }, - "canonicalOwnerEName": { "type": "string" }, - "canonicalEnvelopeId": { "type": ["string", "null"] }, - "type": { "type": "string" } + "id": { + "type": "string" + }, + "ontologyId": { + "type": "string" + }, + "canonicalOwnerEName": { + "type": "string" + }, + "canonicalEnvelopeId": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + } }, - "required": ["id"] + "required": [ + "id" + ] }, "responseOptions": { "type": "array", @@ -32,27 +64,66 @@ "items": { "type": "object", "properties": { - "id": { "type": "string", "description": "Stable id; group responses by this (or value) for a tally" }, - "label": { "type": "string", "description": "Human-visible button text" }, - "value": { "type": "string", "description": "Semantic value recorded in the response (defaults to id)" }, - "kind": { "type": "string", "enum": ["choice"], "default": "choice" } + "id": { + "type": "string", + "description": "Stable id; group responses by this (or value) for a tally" + }, + "label": { + "type": "string", + "description": "Human-visible button text" + }, + "value": { + "type": "string", + "description": "Semantic value recorded in the response (defaults to id)" + }, + "kind": { + "type": "string", + "enum": [ + "choice" + ], + "default": "choice" + } }, - "required": ["id", "label"] + "required": [ + "id", + "label" + ] } }, "display": { "type": "object", "description": "Presentation HINTS only — renderers MAY ignore. Keeps UI out of the domain fields", "properties": { - "pinned": { "type": "boolean" }, - "removable": { "type": "boolean" }, - "category": { "type": "string" } + "pinned": { + "type": "boolean" + }, + "removable": { + "type": "boolean" + }, + "category": { + "type": "string" + } } }, - "authorEName": { "type": "string", "description": "eName of the producing platform/app" }, - "createdAt": { "type": "string", "format": "date-time" }, - "isArchived": { "type": "boolean", "default": false } + "authorEName": { + "type": "string", + "description": "eName of the producing platform/app" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "isArchived": { + "type": "boolean", + "default": false + } }, - "required": ["id", "chatId", "activityType", "summary", "createdAt"], + "required": [ + "id", + "chatId", + "activityType", + "summary", + "createdAt" + ], "additionalProperties": true } diff --git a/services/ontology/schemas/company.json b/services/ontology/schemas/company.json index 3577d2583..54786c052 100644 --- a/services/ontology/schemas/company.json +++ b/services/ontology/schemas/company.json @@ -2,14 +2,36 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402", "title": "Company", + "domain": "work", "type": "object", "properties": { - "id": { "type": "string", "description": "Application-level company identifier" }, - "eName": { "type": "string", "description": "eName of the company eVault" }, - "groupManifestEnvelopeId": { "type": "string", "description": "MetaEnvelope ID of the canonical GroupManifest" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string", + "description": "Application-level company identifier" + }, + "eName": { + "type": "string", + "description": "eName of the company eVault" + }, + "groupManifestEnvelopeId": { + "type": "string", + "description": "MetaEnvelope ID of the canonical GroupManifest" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "eName", "groupManifestEnvelopeId", "createdAt", "updatedAt"], + "required": [ + "id", + "eName", + "groupManifestEnvelopeId", + "createdAt", + "updatedAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/companyProjectLink.json b/services/ontology/schemas/companyProjectLink.json index 7c0127a4b..199112dc6 100644 --- a/services/ontology/schemas/companyProjectLink.json +++ b/services/ontology/schemas/companyProjectLink.json @@ -2,17 +2,45 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f409", "title": "CompanyProjectLink", + "domain": "work", "type": "object", "properties": { - "id": { "type": "string" }, - "companyId": { "type": "string" }, - "companyEName": { "type": "string" }, - "projectId": { "type": "string" }, - "projectEName": { "type": "string" }, - "canonicalOwnerEName": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "companyId": { + "type": "string" + }, + "companyEName": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectEName": { + "type": "string" + }, + "canonicalOwnerEName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "companyId", "companyEName", "projectId", "projectEName", "canonicalOwnerEName", "createdAt", "updatedAt"], + "required": [ + "id", + "companyId", + "companyEName", + "projectId", + "projectEName", + "canonicalOwnerEName", + "createdAt", + "updatedAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/contributorCapacity.json b/services/ontology/schemas/contributorCapacity.json index 8b37acc32..b94d9b123 100644 --- a/services/ontology/schemas/contributorCapacity.json +++ b/services/ontology/schemas/contributorCapacity.json @@ -2,14 +2,37 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f404", "title": "ContributorCapacity", + "domain": "work", "type": "object", "properties": { - "id": { "type": "string", "description": "Capacity record identifier" }, - "personEName": { "type": "string", "description": "eName of the contributor" }, - "weeklyW3dsHoursBudget": { "type": "number", "minimum": 0, "description": "Hours available per week" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string", + "description": "Capacity record identifier" + }, + "personEName": { + "type": "string", + "description": "eName of the contributor" + }, + "weeklyW3dsHoursBudget": { + "type": "number", + "minimum": 0, + "description": "Hours available per week" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "personEName", "weeklyW3dsHoursBudget", "createdAt", "updatedAt"], + "required": [ + "id", + "personEName", + "weeklyW3dsHoursBudget", + "createdAt", + "updatedAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/currency.json b/services/ontology/schemas/currency.json index 1e2cd81c6..0884caa02 100644 --- a/services/ontology/schemas/currency.json +++ b/services/ontology/schemas/currency.json @@ -1,46 +1,51 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440008", - "title": "Currency", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the currency" - }, - "name": { - "type": "string", - "description": "Human-readable name of the currency" - }, - "ename": { - "type": "string", - "description": "eName (W3ID) of the currency owner" - }, - "groupId": { - "type": "string", - "format": "uuid", - "description": "ID of the group this currency belongs to" - }, - "allowNegative": { - "type": "boolean", - "description": "Whether account balances may go negative" - }, - "createdBy": { - "type": "string", - "description": "ID or eName of the user who created the currency" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the currency was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the currency was last updated" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440008", + "title": "Currency", + "domain": "finance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the currency" }, - "required": ["name", "ename", "createdAt"], - "additionalProperties": false + "name": { + "type": "string", + "description": "Human-readable name of the currency" + }, + "ename": { + "type": "string", + "description": "eName (W3ID) of the currency owner" + }, + "groupId": { + "type": "string", + "format": "uuid", + "description": "ID of the group this currency belongs to" + }, + "allowNegative": { + "type": "boolean", + "description": "Whether account balances may go negative" + }, + "createdBy": { + "type": "string", + "description": "ID or eName of the user who created the currency" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the currency was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the currency was last updated" + } + }, + "required": [ + "name", + "ename", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/file.json b/services/ontology/schemas/file.json index d4962a206..028e8240c 100644 --- a/services/ontology/schemas/file.json +++ b/services/ontology/schemas/file.json @@ -1,71 +1,85 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "title": "File", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the file" - }, - "name": { - "type": "string", - "description": "The original file name" - }, - "displayName": { - "type": "string", - "description": "Custom display name for the file" - }, - "description": { - "type": "string", - "description": "Optional description of the file" - }, - "mimeType": { - "type": "string", - "description": "MIME type of the file (e.g., application/pdf, image/png)" - }, - "size": { - "type": "integer", - "minimum": 0, - "description": "File size in bytes" - }, - "md5Hash": { - "type": "string", - "description": "MD5 hash of the file content for integrity verification" - }, - "data": { - "type": "string", - "format": "base64", - "description": "Base64-encoded file content (binary data, legacy)" - }, - "url": { - "type": ["string", "null"], - "format": "uri", - "description": "URL to the file stored in S3-compatible object storage" - }, - "ownerId": { - "type": "string", - "format": "uuid", - "description": "ID of the user who owns the file" - }, - "folderId": { - "type": ["string", "null"], - "format": "uuid", - "description": "ID of the folder containing the file (null for root level)" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the file was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the file was last updated" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "File", + "domain": "storage", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the file" }, - "required": ["id", "name", "mimeType", "size", "md5Hash", "ownerId", "createdAt"], - "additionalProperties": false + "name": { + "type": "string", + "description": "The original file name" + }, + "displayName": { + "type": "string", + "description": "Custom display name for the file" + }, + "description": { + "type": "string", + "description": "Optional description of the file" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file (e.g., application/pdf, image/png)" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "md5Hash": { + "type": "string", + "description": "MD5 hash of the file content for integrity verification" + }, + "data": { + "type": "string", + "format": "base64", + "description": "Base64-encoded file content (binary data, legacy)" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "URL to the file stored in S3-compatible object storage" + }, + "ownerId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who owns the file" + }, + "folderId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "ID of the folder containing the file (null for root level)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the file was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the file was last updated" + } + }, + "required": [ + "id", + "name", + "mimeType", + "size", + "md5Hash", + "ownerId", + "createdAt" + ], + "additionalProperties": false } - diff --git a/services/ontology/schemas/fileSignature.json b/services/ontology/schemas/fileSignature.json index 733646c16..e5e92f91b 100644 --- a/services/ontology/schemas/fileSignature.json +++ b/services/ontology/schemas/fileSignature.json @@ -1,52 +1,61 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "title": "Signature", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the signature" - }, - "fileId": { - "type": "string", - "format": "uuid", - "description": "ID of the file that was signed" - }, - "userId": { - "type": "string", - "format": "uuid", - "description": "ID of the user who created the signature" - }, - "md5Hash": { - "type": "string", - "description": "MD5 hash of the file content at the time of signing" - }, - "signature": { - "type": "string", - "description": "Cryptographic signature proving the user's agreement to the file" - }, - "publicKey": { - "type": "string", - "description": "User's public key for signature verification" - }, - "message": { - "type": "string", - "description": "Original message that was signed (usually contains file details)" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the signature was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the signature was last updated" - } - }, - "required": ["id", "fileId", "userId", "md5Hash", "signature", "publicKey", "message", "createdAt"], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "title": "Signature", + "domain": "legal", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the signature" + }, + "fileId": { + "type": "string", + "format": "uuid", + "description": "ID of the file that was signed" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who created the signature" + }, + "md5Hash": { + "type": "string", + "description": "MD5 hash of the file content at the time of signing" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature proving the user's agreement to the file" + }, + "publicKey": { + "type": "string", + "description": "User's public key for signature verification" + }, + "message": { + "type": "string", + "description": "Original message that was signed (usually contains file details)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was last updated" + } + }, + "required": [ + "id", + "fileId", + "userId", + "md5Hash", + "signature", + "publicKey", + "message", + "createdAt" + ], + "additionalProperties": false } - diff --git a/services/ontology/schemas/groupManifest.json b/services/ontology/schemas/groupManifest.json index 2d281fe05..883951869 100644 --- a/services/ontology/schemas/groupManifest.json +++ b/services/ontology/schemas/groupManifest.json @@ -1,58 +1,65 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "a8bfb7cf-3200-4b25-9ea9-ee41100f212e", - "title": "GroupManifest", - "type": "object", - "properties": { - "eName": { - "type": "string", - "description": "eName of the group" - }, - "name": { - "type": "string", - "description": "Human readable name assigned to the group" - }, - "avatar": { - "type": "string", - "description": "Image assigned to the group" - }, - "description": { - "type": "string", - "description": "Human readable brief description for the group" - }, - "members": { - "type": "array", - "description": "Array of eNames of all members, with an indication of managers and a chair", - "items": { - "type": "string" - } - }, - "charter": { - "type": "string", - "description": "Attached charter document, either in full or in reference (TBD)—if any" - }, - "admins": { - "type": "array", - "description": "Charter allows to add admins. If more than one, need a special one, the Chair. Chair will be considered by Social Nets as an Admin, as they may not see many admins.", - "items": { - "type": "string" - } - }, - "owner": { - "type": "string", - "description": "The eName of the group owner" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the group was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the group manifest was last updated" - } - }, - "required": ["eName", "name", "members", "admins", "owner"], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "a8bfb7cf-3200-4b25-9ea9-ee41100f212e", + "title": "GroupManifest", + "domain": "governance", + "type": "object", + "properties": { + "eName": { + "type": "string", + "description": "eName of the group" + }, + "name": { + "type": "string", + "description": "Human readable name assigned to the group" + }, + "avatar": { + "type": "string", + "description": "Image assigned to the group" + }, + "description": { + "type": "string", + "description": "Human readable brief description for the group" + }, + "members": { + "type": "array", + "description": "Array of eNames of all members, with an indication of managers and a chair", + "items": { + "type": "string" + } + }, + "charter": { + "type": "string", + "description": "Attached charter document, either in full or in reference (TBD)—if any" + }, + "admins": { + "type": "array", + "description": "Charter allows to add admins. If more than one, need a special one, the Chair. Chair will be considered by Social Nets as an Admin, as they may not see many admins.", + "items": { + "type": "string" + } + }, + "owner": { + "type": "string", + "description": "The eName of the group owner" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the group was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the group manifest was last updated" + } + }, + "required": [ + "eName", + "name", + "members", + "admins", + "owner" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/ledger.json b/services/ontology/schemas/ledger.json index 9713c2b9f..7bebe884f 100644 --- a/services/ontology/schemas/ledger.json +++ b/services/ontology/schemas/ledger.json @@ -1,55 +1,56 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440006", - "title": "Ledger", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the ledger entry" - }, - "currencyId": { - "type": "string", - "format": "uuid", - "description": "ID of the currency this ledger entry belongs to" - }, - "accountId": { - "type": "string", - "description": "ID of the account" - }, - "accountType": { - "type": "string", - "description": "Type of account (e.g. user, group)" - }, - "amount": { - "type": "number", - "description": "Transaction amount" - }, - "type": { - "type": "string", - "description": "Type of ledger entry or transaction" - }, - "description": { - "type": "string", - "description": "Optional description of the entry" - }, - "balance": { - "type": "number", - "description": "Account balance after this entry" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the ledger entry was created" - } - }, - "required": [ - "currencyId", - "accountId", - "amount", - "type", - "createdAt" - ], - "additionalProperties": false -} \ No newline at end of file + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440006", + "title": "Ledger", + "domain": "finance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the ledger entry" + }, + "currencyId": { + "type": "string", + "format": "uuid", + "description": "ID of the currency this ledger entry belongs to" + }, + "accountId": { + "type": "string", + "description": "ID of the account" + }, + "accountType": { + "type": "string", + "description": "Type of account (e.g. user, group)" + }, + "amount": { + "type": "number", + "description": "Transaction amount" + }, + "type": { + "type": "string", + "description": "Type of ledger entry or transaction" + }, + "description": { + "type": "string", + "description": "Optional description of the entry" + }, + "balance": { + "type": "number", + "description": "Account balance after this entry" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the ledger entry was created" + } + }, + "required": [ + "currencyId", + "accountId", + "amount", + "type", + "createdAt" + ], + "additionalProperties": false +} diff --git a/services/ontology/schemas/membership.json b/services/ontology/schemas/membership.json index 8ded5dd18..895773b9b 100644 --- a/services/ontology/schemas/membership.json +++ b/services/ontology/schemas/membership.json @@ -2,34 +2,86 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f408", "title": "Membership", + "domain": "community", "oneOf": [ { "title": "CanonicalMembership", "type": "object", "properties": { - "id": { "type": "string" }, - "parentType": { "type": "string", "enum": ["company", "project"] }, - "parentId": { "type": "string" }, - "parentEName": { "type": "string" }, - "memberEName": { "type": "string" }, - "role": { "type": "string", "enum": ["participant", "admin"] }, - "canonicalOwnerEName": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "parentType": { + "type": "string", + "enum": [ + "company", + "project" + ] + }, + "parentId": { + "type": "string" + }, + "parentEName": { + "type": "string" + }, + "memberEName": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "participant", + "admin" + ] + }, + "canonicalOwnerEName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "parentType", "parentId", "parentEName", "memberEName", "role", "canonicalOwnerEName", "createdAt", "updatedAt"], + "required": [ + "id", + "parentType", + "parentId", + "parentEName", + "memberEName", + "role", + "canonicalOwnerEName", + "createdAt", + "updatedAt" + ], "additionalProperties": false }, { "title": "MembershipReference", "type": "object", "properties": { - "isReference": { "const": true }, - "canonicalMembershipId": { "type": "string" }, - "canonicalOwnerEName": { "type": "string" }, - "canonicalEnvelopeId": { "type": "string" } + "isReference": { + "const": true + }, + "canonicalMembershipId": { + "type": "string" + }, + "canonicalOwnerEName": { + "type": "string" + }, + "canonicalEnvelopeId": { + "type": "string" + } }, - "required": ["isReference", "canonicalMembershipId", "canonicalOwnerEName", "canonicalEnvelopeId"], + "required": [ + "isReference", + "canonicalMembershipId", + "canonicalOwnerEName", + "canonicalEnvelopeId" + ], "additionalProperties": false } ] diff --git a/services/ontology/schemas/message.json b/services/ontology/schemas/message.json index d690e0f5b..b3bcaa904 100644 --- a/services/ontology/schemas/message.json +++ b/services/ontology/schemas/message.json @@ -1,61 +1,74 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440004", - "title": "Message", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the message" - }, - "chatId": { - "type": "string", - "format": "uuid", - "description": "The ID of the chat this message belongs to" - }, - "senderId": { - "type": "string", - "format": "uuid", - "description": "The ID of the user who sent the message" - }, - "content": { - "type": "string", - "description": "The text content of the message" - }, - "type": { - "type": "string", - "enum": ["text", "image", "file", "system"], - "description": "The type of message content" - }, - "mediaUrl": { - "type": "string", - "format": "uri", - "description": "URL to media attachment if message type is image or file" - }, - "readBy": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "description": "Array of user IDs who have read the message" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the message was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the message was last updated" - }, - "isArchived": { - "type": "boolean", - "description": "Whether the message is archived" - } - }, - "required": ["id", "chatId", "senderId", "content", "type", "createdAt"], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "title": "Message", + "domain": "communication", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the message" + }, + "chatId": { + "type": "string", + "format": "uuid", + "description": "The ID of the chat this message belongs to" + }, + "senderId": { + "type": "string", + "format": "uuid", + "description": "The ID of the user who sent the message" + }, + "content": { + "type": "string", + "description": "The text content of the message" + }, + "type": { + "type": "string", + "enum": [ + "text", + "image", + "file", + "system" + ], + "description": "The type of message content" + }, + "mediaUrl": { + "type": "string", + "format": "uri", + "description": "URL to media attachment if message type is image or file" + }, + "readBy": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Array of user IDs who have read the message" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the message was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the message was last updated" + }, + "isArchived": { + "type": "boolean", + "description": "Whether the message is archived" + } + }, + "required": [ + "id", + "chatId", + "senderId", + "content", + "type", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/poll.json b/services/ontology/schemas/poll.json index f5343c707..93bdc2515 100644 --- a/services/ontology/schemas/poll.json +++ b/services/ontology/schemas/poll.json @@ -1,63 +1,68 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "660e8400-e29b-41d4-a716-446655440100", - "title": "Poll", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the poll" - }, - "title": { - "type": "string", - "description": "Title of the poll" - }, - "mode": { - "type": "string", - "description": "Voting mode (e.g. single, multiple)" - }, - "visibility": { - "type": "string", - "description": "Visibility of the poll (e.g. public, private)" - }, - "votingWeight": { - "type": "string", - "description": "How voting weight is determined (e.g. one-per-user, reputation-weighted)" - }, - "options": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Poll options to vote on" - }, - "deadline": { - "type": "string", - "format": "date-time", - "description": "When voting closes" - }, - "creatorId": { - "type": "string", - "format": "uuid", - "description": "ID of the user who created the poll" - }, - "group": { - "type": "string", - "format": "uuid", - "description": "Global ID of the group this poll belongs to" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the poll was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the poll was last updated" - } - }, - "required": ["id", "title", "createdAt"], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "660e8400-e29b-41d4-a716-446655440100", + "title": "Poll", + "domain": "governance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the poll" + }, + "title": { + "type": "string", + "description": "Title of the poll" + }, + "mode": { + "type": "string", + "description": "Voting mode (e.g. single, multiple)" + }, + "visibility": { + "type": "string", + "description": "Visibility of the poll (e.g. public, private)" + }, + "votingWeight": { + "type": "string", + "description": "How voting weight is determined (e.g. one-per-user, reputation-weighted)" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Poll options to vote on" + }, + "deadline": { + "type": "string", + "format": "date-time", + "description": "When voting closes" + }, + "creatorId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who created the poll" + }, + "group": { + "type": "string", + "format": "uuid", + "description": "Global ID of the group this poll belongs to" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the poll was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the poll was last updated" + } + }, + "required": [ + "id", + "title", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/professionalProfile.json b/services/ontology/schemas/professionalProfile.json index 00f05fb6c..64912fb2d 100644 --- a/services/ontology/schemas/professionalProfile.json +++ b/services/ontology/schemas/professionalProfile.json @@ -1,181 +1,197 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440009", - "title": "ProfessionalProfile", - "type": "object", - "properties": { - "displayName": { - "type": "string", - "description": "The user's display name" - }, - "headline": { + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440009", + "title": "ProfessionalProfile", + "domain": "work", + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "The user's display name" + }, + "headline": { + "type": "string", + "description": "A short professional headline or tagline" + }, + "bio": { + "type": "string", + "description": "Professional biography or summary" + }, + "avatar": { + "type": "string", + "description": "ID for the user's profile picture" + }, + "banner": { + "type": "string", + "description": "file ID for the user's profile banner" + }, + "cvFileId": { + "type": "string", + "description": "file ID for the user's CV/resume document" + }, + "videoIntroFileId": { + "type": "string", + "description": "file ID for the user's video introduction" + }, + "email": { + "type": "string", + "format": "email", + "description": "Professional contact email" + }, + "phone": { + "type": "string", + "description": "Professional contact phone number" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Personal or professional website URL" + }, + "location": { + "type": "string", + "description": "Professional location or city" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the professional profile is publicly visible" + }, + "workExperience": { + "type": "array", + "description": "List of work experience entries", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", - "description": "A short professional headline or tagline" - }, - "bio": { + "description": "Unique identifier for the entry" + }, + "company": { "type": "string", - "description": "Professional biography or summary" - }, - "avatar": { + "description": "Company or organization name" + }, + "role": { "type": "string", - "description": "ID for the user's profile picture" - }, - "banner": { + "description": "Job title or role" + }, + "description": { "type": "string", - "description": "file ID for the user's profile banner" - }, - "cvFileId": { + "description": "Description of responsibilities and achievements" + }, + "startDate": { "type": "string", - "description": "file ID for the user's CV/resume document" - }, - "videoIntroFileId": { + "format": "date", + "description": "Start date of the position" + }, + "endDate": { "type": "string", - "description": "file ID for the user's video introduction" - }, - "email": { + "format": "date", + "description": "End date of the position (omit if current)" + }, + "location": { "type": "string", - "format": "email", - "description": "Professional contact email" + "description": "Location of the position" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } }, - "phone": { + "required": [ + "company", + "role", + "startDate", + "sortOrder" + ] + } + }, + "education": { + "type": "array", + "description": "List of education entries", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entry" + }, + "institution": { + "type": "string", + "description": "Educational institution name" + }, + "degree": { + "type": "string", + "description": "Degree or qualification obtained" + }, + "fieldOfStudy": { "type": "string", - "description": "Professional contact phone number" + "description": "Field or area of study" + }, + "startDate": { + "type": "string", + "format": "date", + "description": "Start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "End date (omit if ongoing)" + }, + "description": { + "type": "string", + "description": "Additional details about the education" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } }, - "website": { + "required": [ + "institution", + "degree", + "startDate", + "sortOrder" + ] + } + }, + "skills": { + "type": "array", + "description": "List of professional skills", + "items": { + "type": "string" + } + }, + "socialLinks": { + "type": "array", + "description": "List of social media or professional links", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the link" + }, + "platform": { + "type": "string", + "description": "Platform name (e.g. LinkedIn, GitHub, Twitter)" + }, + "url": { "type": "string", "format": "uri", - "description": "Personal or professional website URL" - }, - "location": { + "description": "URL to the profile on the platform" + }, + "label": { "type": "string", - "description": "Professional location or city" - }, - "isPublic": { - "type": "boolean", - "description": "Whether the professional profile is publicly visible" + "description": "Optional display label" + } }, - "workExperience": { - "type": "array", - "description": "List of work experience entries", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the entry" - }, - "company": { - "type": "string", - "description": "Company or organization name" - }, - "role": { - "type": "string", - "description": "Job title or role" - }, - "description": { - "type": "string", - "description": "Description of responsibilities and achievements" - }, - "startDate": { - "type": "string", - "format": "date", - "description": "Start date of the position" - }, - "endDate": { - "type": "string", - "format": "date", - "description": "End date of the position (omit if current)" - }, - "location": { - "type": "string", - "description": "Location of the position" - }, - "sortOrder": { - "type": "integer", - "description": "Display order" - } - }, - "required": ["company", "role", "startDate", "sortOrder"] - } - }, - "education": { - "type": "array", - "description": "List of education entries", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the entry" - }, - "institution": { - "type": "string", - "description": "Educational institution name" - }, - "degree": { - "type": "string", - "description": "Degree or qualification obtained" - }, - "fieldOfStudy": { - "type": "string", - "description": "Field or area of study" - }, - "startDate": { - "type": "string", - "format": "date", - "description": "Start date" - }, - "endDate": { - "type": "string", - "format": "date", - "description": "End date (omit if ongoing)" - }, - "description": { - "type": "string", - "description": "Additional details about the education" - }, - "sortOrder": { - "type": "integer", - "description": "Display order" - } - }, - "required": ["institution", "degree", "startDate", "sortOrder"] - } - }, - "skills": { - "type": "array", - "description": "List of professional skills", - "items": { - "type": "string" - } - }, - "socialLinks": { - "type": "array", - "description": "List of social media or professional links", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the link" - }, - "platform": { - "type": "string", - "description": "Platform name (e.g. LinkedIn, GitHub, Twitter)" - }, - "url": { - "type": "string", - "format": "uri", - "description": "URL to the profile on the platform" - }, - "label": { - "type": "string", - "description": "Optional display label" - } - }, - "required": ["platform", "url"] - } - } - }, - "required": ["displayName"] + "required": [ + "platform", + "url" + ] + } + } + }, + "required": [ + "displayName" + ] } diff --git a/services/ontology/schemas/project.json b/services/ontology/schemas/project.json index ed7f0be05..6b51e5fd0 100644 --- a/services/ontology/schemas/project.json +++ b/services/ontology/schemas/project.json @@ -2,19 +2,44 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f403", "title": "Project", + "domain": "work", "type": "object", "properties": { - "id": { "type": "string", "description": "Application-level project identifier" }, - "eName": { "type": "string", "description": "eName of the project eVault" }, - "groupManifestEnvelopeId": { "type": "string", "description": "MetaEnvelope ID of the canonical GroupManifest" }, + "id": { + "type": "string", + "description": "Application-level project identifier" + }, + "eName": { + "type": "string", + "description": "eName of the project eVault" + }, + "groupManifestEnvelopeId": { + "type": "string", + "description": "MetaEnvelope ID of the canonical GroupManifest" + }, "grantsAccessRelatedProjectIds": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Projects whose members inherit access through this project" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "eName", "groupManifestEnvelopeId", "grantsAccessRelatedProjectIds", "createdAt", "updatedAt"], + "required": [ + "id", + "eName", + "groupManifestEnvelopeId", + "grantsAccessRelatedProjectIds", + "createdAt", + "updatedAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/reference.json b/services/ontology/schemas/reference.json index af4fedabc..092265282 100644 --- a/services/ontology/schemas/reference.json +++ b/services/ontology/schemas/reference.json @@ -1,60 +1,70 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "c20e9437-02a4-4917-8cee-de35dabbdb6a", - "title": "Reference", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the reference" - }, - "targetType": { - "type": "string", - "description": "Type of target (e.g. user, group, platform)" - }, - "targetId": { - "type": "string", - "description": "ID of the entity being referenced" - }, - "targetName": { - "type": "string", - "description": "Display name of the target" - }, - "content": { - "type": "string", - "description": "Text content of the reference" - }, - "referenceType": { - "type": "string", - "description": "Type of reference (e.g. general, professional)" - }, - "numericScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "description": "Optional 1-5 score" - }, - "authorId": { - "type": "string", - "format": "uuid", - "description": "ID of the user who wrote the reference" - }, - "signature": { - "type": "string", - "description": "Signature over the reference (e.g. W3DS or platform signature)" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the reference was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the reference was last updated" - } - }, - "required": ["id", "targetType", "targetId", "targetName", "content", "referenceType", "authorId", "createdAt"], - "additionalProperties": false + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "c20e9437-02a4-4917-8cee-de35dabbdb6a", + "title": "Reference", + "domain": "reputation", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the reference" + }, + "targetType": { + "type": "string", + "description": "Type of target (e.g. user, group, platform)" + }, + "targetId": { + "type": "string", + "description": "ID of the entity being referenced" + }, + "targetName": { + "type": "string", + "description": "Display name of the target" + }, + "content": { + "type": "string", + "description": "Text content of the reference" + }, + "referenceType": { + "type": "string", + "description": "Type of reference (e.g. general, professional)" + }, + "numericScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Optional 1-5 score" + }, + "authorId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who wrote the reference" + }, + "signature": { + "type": "string", + "description": "Signature over the reference (e.g. W3DS or platform signature)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the reference was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the reference was last updated" + } + }, + "required": [ + "id", + "targetType", + "targetId", + "targetName", + "content", + "referenceType", + "authorId", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/socialMediaPost.json b/services/ontology/schemas/socialMediaPost.json index 71d11ebf2..ab5c92166 100644 --- a/services/ontology/schemas/socialMediaPost.json +++ b/services/ontology/schemas/socialMediaPost.json @@ -1,93 +1,94 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440001", - "title": "SocialMediaPost", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the post" - }, - "authorId": { - "type": "string", - "format": "uuid", - "description": "The ID of the user who created the post" - }, - "content": { - "type": "string", - "description": "The main text content of the post" - }, - "mediaUrls": { - "type": "array", - "items": { - "type": "string", - "format": "uri" - }, - "description": "Array of URLs to media attachments (images, videos, etc.)" - }, - "parentPostId": { - "type": "string", - "format": "uuid", - "description": "ID of the parent post if this is a reply" - }, - "hashtags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of hashtags used in the post" - }, - "likeCount": { - "type": "integer", - "minimum": 0, - "description": "Number of likes on the post" - }, - "likedBy": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Number of likes on the post" - }, - "replyCount": { - "type": "integer", - "minimum": 0, - "description": "Number of replies to the post" - }, - "repostCount": { - "type": "integer", - "minimum": 0, - "description": "Number of times the post has been reposted" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the post was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the post was last updated" - }, - "isArchived": { - "type": "boolean", - "description": "Whether the post is archived" - }, - "visibility": { - "type": "string", - "enum": [ - "public", - "private", - "followers" - ], - "description": "The visibility level of the post" - } + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440001", + "title": "SocialMediaPost", + "domain": "social", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the post" }, - "required": [ - "id", - "authorId", - "createdAt" - ], - "additionalProperties": false + "authorId": { + "type": "string", + "format": "uuid", + "description": "The ID of the user who created the post" + }, + "content": { + "type": "string", + "description": "The main text content of the post" + }, + "mediaUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "description": "Array of URLs to media attachments (images, videos, etc.)" + }, + "parentPostId": { + "type": "string", + "format": "uuid", + "description": "ID of the parent post if this is a reply" + }, + "hashtags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of hashtags used in the post" + }, + "likeCount": { + "type": "integer", + "minimum": 0, + "description": "Number of likes on the post" + }, + "likedBy": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Number of likes on the post" + }, + "replyCount": { + "type": "integer", + "minimum": 0, + "description": "Number of replies to the post" + }, + "repostCount": { + "type": "integer", + "minimum": 0, + "description": "Number of times the post has been reposted" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the post was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the post was last updated" + }, + "isArchived": { + "type": "boolean", + "description": "Whether the post is archived" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "followers" + ], + "description": "The visibility level of the post" + } + }, + "required": [ + "id", + "authorId", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/task.json b/services/ontology/schemas/task.json index fbed2dae0..ce71247d8 100644 --- a/services/ontology/schemas/task.json +++ b/services/ontology/schemas/task.json @@ -2,78 +2,254 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f401", "title": "Task", + "domain": "productivity", "type": "object", "definitions": { "note": { "type": "object", "properties": { - "id": { "type": "string" }, - "taskId": { "type": "string" }, - "authorEName": { "type": "string" }, - "content": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "authorEName": { + "type": "string" + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "taskId", "authorEName", "content", "createdAt"], + "required": [ + "id", + "taskId", + "authorEName", + "content", + "createdAt" + ], "additionalProperties": false }, "attachment": { "type": "object", "properties": { - "id": { "type": "string" }, - "taskId": { "type": "string" }, - "fileName": { "type": "string" }, - "url": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "taskId", "fileName", "url", "createdAt"], + "required": [ + "id", + "taskId", + "fileName", + "url", + "createdAt" + ], "additionalProperties": false }, "subtask": { "type": "object", "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "done": { "type": "boolean" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "done": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "title", "done"], + "required": [ + "id", + "title", + "done" + ], "additionalProperties": false } }, "properties": { - "id": { "type": "string", "description": "Logical task identifier" }, - "canonicalOwnerEName": { "type": "string", "description": "eName of the eVault holding this canonical task" }, - "homeProjectId": { "type": "string" }, - "relatedProjectIds": { "type": "array", "items": { "type": "string" } }, - "relatedCompanyIds": { "type": "array", "items": { "type": "string" } }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "createdBy": { "type": "string", "description": "Reporter eName" }, - "assignees": { "type": "array", "items": { "type": "string" } }, - "status": { "type": "string", "enum": ["planned", "in_progress", "done"] }, + "id": { + "type": "string", + "description": "Logical task identifier" + }, + "canonicalOwnerEName": { + "type": "string", + "description": "eName of the eVault holding this canonical task" + }, + "homeProjectId": { + "type": "string" + }, + "relatedProjectIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "relatedCompanyIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "createdBy": { + "type": "string", + "description": "Reporter eName" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": [ + "planned", + "in_progress", + "done" + ] + }, "intakeStatus": { "type": "string", - "enum": ["draft", "ready"], + "enum": [ + "draft", + "ready" + ], "description": "Cross-application intake state. Missing values are treated as legacy ready tasks unless required task fields are incomplete." }, - "priority": { "type": "integer", "enum": [0, 1, 2, 3] }, - "progressPercent": { "type": "number", "minimum": 0, "maximum": 100 }, - "estimatedHours": { "type": ["number", "null"], "minimum": 0 }, - "deadline": { "type": ["string", "null"], "format": "date-time" }, - "blockedByTaskIds": { "type": "array", "items": { "type": "string" } }, - "blocksTaskIds": { "type": "array", "items": { "type": "string" } }, - "notes": { "type": "array", "items": { "$ref": "#/definitions/note" } }, - "attachments": { "type": "array", "items": { "$ref": "#/definitions/attachment" } }, - "subtasks": { "type": "array", "items": { "$ref": "#/definitions/subtask" } }, - "effectiveAcl": { "type": "array", "items": { "type": "string" } }, - "manualAclAdds": { "type": "array", "items": { "type": "string" } }, - "aiDraftSource": { "type": "string" }, - "eVaultSyncStatus": { "type": "string", "enum": ["synced", "pending", "failed"] }, - "eVaultSyncError": { "type": ["string", "null"] }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "closedAt": { "type": ["string", "null"], "format": "date-time" } + "priority": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "progressPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "estimatedHours": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "deadline": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "blockedByTaskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocksTaskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "notes": { + "type": "array", + "items": { + "$ref": "#/definitions/note" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/attachment" + } + }, + "subtasks": { + "type": "array", + "items": { + "$ref": "#/definitions/subtask" + } + }, + "effectiveAcl": { + "type": "array", + "items": { + "type": "string" + } + }, + "manualAclAdds": { + "type": "array", + "items": { + "type": "string" + } + }, + "aiDraftSource": { + "type": "string" + }, + "eVaultSyncStatus": { + "type": "string", + "enum": [ + "synced", + "pending", + "failed" + ] + }, + "eVaultSyncError": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } }, "required": [ "id", diff --git a/services/ontology/schemas/taskAttachment.json b/services/ontology/schemas/taskAttachment.json index 73dc0e132..10d9679a2 100644 --- a/services/ontology/schemas/taskAttachment.json +++ b/services/ontology/schemas/taskAttachment.json @@ -2,15 +2,36 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f407", "title": "TaskAttachment", + "domain": "productivity", "type": "object", "properties": { - "id": { "type": "string" }, - "taskId": { "type": "string" }, - "fileName": { "type": "string" }, - "url": { "type": "string" }, - "canonicalOwnerEName": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "canonicalOwnerEName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "taskId", "fileName", "url", "canonicalOwnerEName", "createdAt"], + "required": [ + "id", + "taskId", + "fileName", + "url", + "canonicalOwnerEName", + "createdAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/taskNote.json b/services/ontology/schemas/taskNote.json index ec9290be0..53fd636fc 100644 --- a/services/ontology/schemas/taskNote.json +++ b/services/ontology/schemas/taskNote.json @@ -2,15 +2,36 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f406", "title": "TaskNote", + "domain": "productivity", "type": "object", "properties": { - "id": { "type": "string" }, - "taskId": { "type": "string" }, - "authorEName": { "type": "string" }, - "content": { "type": "string" }, - "canonicalOwnerEName": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "authorEName": { + "type": "string" + }, + "content": { + "type": "string" + }, + "canonicalOwnerEName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["id", "taskId", "authorEName", "content", "canonicalOwnerEName", "createdAt"], + "required": [ + "id", + "taskId", + "authorEName", + "content", + "canonicalOwnerEName", + "createdAt" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/taskReference.json b/services/ontology/schemas/taskReference.json index aefd07286..bc09bc495 100644 --- a/services/ontology/schemas/taskReference.json +++ b/services/ontology/schemas/taskReference.json @@ -2,19 +2,40 @@ "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f405", "title": "TaskReference", + "domain": "productivity", "type": "object", "properties": { - "isReference": { "const": true }, - "canonicalTaskId": { "type": "string", "description": "Logical ID of the canonical task" }, - "canonicalOwnerEName": { "type": "string", "description": "eName of the eVault holding the canonical task" }, - "canonicalEnvelopeId": { "type": "string", "description": "MetaEnvelope ID of the current canonical task" }, + "isReference": { + "const": true + }, + "canonicalTaskId": { + "type": "string", + "description": "Logical ID of the canonical task" + }, + "canonicalOwnerEName": { + "type": "string", + "description": "eName of the eVault holding the canonical task" + }, + "canonicalEnvelopeId": { + "type": "string", + "description": "MetaEnvelope ID of the current canonical task" + }, "referenceType": { "type": "string", - "enum": ["canonical-relocation"], + "enum": [ + "canonical-relocation" + ], "description": "Present when a former canonical Task envelope redirects to a relocated canonical envelope" }, - "relocatedAt": { "type": "string", "format": "date-time" } + "relocatedAt": { + "type": "string", + "format": "date-time" + } }, - "required": ["isReference", "canonicalTaskId", "canonicalOwnerEName"], + "required": [ + "isReference", + "canonicalTaskId", + "canonicalOwnerEName" + ], "additionalProperties": false } diff --git a/services/ontology/schemas/user.json b/services/ontology/schemas/user.json index 3b23fb7cb..1d5792d28 100644 --- a/services/ontology/schemas/user.json +++ b/services/ontology/schemas/user.json @@ -1,127 +1,133 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "550e8400-e29b-41d4-a716-446655440000", - "title": "User", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the user" - }, - "username": { - "type": "string", - "description": "The user's unique username/handle across the platform" - }, - "displayName": { - "type": "string", - "description": "The user's display name or full name" - }, - "givenName": { - "type": "string", - "description": "The user's given name" - }, - "familyName": { - "type": "string", - "description": "The user's family name" - }, - "email": { - "type": "string", - "format": "email", - "description": "The user's email address" - }, - "telephone": { - "type": "string", - "description": "The user's telephone number" - }, - "address": { - "type": "object", - "description": "The user's postal address", - "properties": { - "streetAddress": { - "type": "string", - "description": "The street address" - }, - "postalCode": { - "type": "string", - "description": "The postal code" - }, - "addressLocality": { - "type": "string", - "description": "The locality or city" - }, - "addressCountry": { - "type": "string", - "description": "The country" - } - }, - "additionalProperties": false - }, - "inLanguage": { - "type": "string", - "description": "The language associated with the user profile" - }, - "bio": { - "type": "string", - "description": "The user's biography or description" - }, - "avatarUrl": { - "type": "string", - "format": "uri", - "description": "URL to the user's profile picture" - }, - "bannerUrl": { - "type": "string", - "format": "uri", - "description": "URL to the user's profile banner/cover photo" - }, - "website": { - "type": "string", - "format": "uri", - "description": "The user's personal website URL" - }, - "location": { - "type": "string", - "description": "The user's physical location" - }, - "isVerified": { - "type": "boolean", - "description": "Whether the user's account is verified" - }, - "isPrivate": { - "type": "boolean", - "description": "Whether the user's account is private" - }, - "followerCount": { - "type": "integer", - "minimum": 0, - "description": "Number of followers the user has" - }, - "followingCount": { - "type": "integer", - "minimum": 0, - "description": "Number of users this user follows" - }, - "postCount": { - "type": "integer", - "minimum": 0, - "description": "Number of posts created by the user" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the user account was created" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the user profile was last updated" - }, - "isArchived": { - "type": "boolean", - "description": "Whether the user account is archived" + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "title": "User", + "domain": "identity", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the user" + }, + "username": { + "type": "string", + "description": "The user's unique username/handle across the platform" + }, + "displayName": { + "type": "string", + "description": "The user's display name or full name" + }, + "givenName": { + "type": "string", + "description": "The user's given name" + }, + "familyName": { + "type": "string", + "description": "The user's family name" + }, + "email": { + "type": "string", + "format": "email", + "description": "The user's email address" + }, + "telephone": { + "type": "string", + "description": "The user's telephone number" + }, + "address": { + "type": "object", + "description": "The user's postal address", + "properties": { + "streetAddress": { + "type": "string", + "description": "The street address" + }, + "postalCode": { + "type": "string", + "description": "The postal code" + }, + "addressLocality": { + "type": "string", + "description": "The locality or city" + }, + "addressCountry": { + "type": "string", + "description": "The country" } + }, + "additionalProperties": false + }, + "inLanguage": { + "type": "string", + "description": "The language associated with the user profile" + }, + "bio": { + "type": "string", + "description": "The user's biography or description" + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "description": "URL to the user's profile picture" + }, + "bannerUrl": { + "type": "string", + "format": "uri", + "description": "URL to the user's profile banner/cover photo" + }, + "website": { + "type": "string", + "format": "uri", + "description": "The user's personal website URL" + }, + "location": { + "type": "string", + "description": "The user's physical location" + }, + "isVerified": { + "type": "boolean", + "description": "Whether the user's account is verified" + }, + "isPrivate": { + "type": "boolean", + "description": "Whether the user's account is private" + }, + "followerCount": { + "type": "integer", + "minimum": 0, + "description": "Number of followers the user has" + }, + "followingCount": { + "type": "integer", + "minimum": 0, + "description": "Number of users this user follows" + }, + "postCount": { + "type": "integer", + "minimum": 0, + "description": "Number of posts created by the user" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the user account was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the user profile was last updated" }, - "required": ["id", "username", "displayName", "createdAt"], - "additionalProperties": false + "isArchived": { + "type": "boolean", + "description": "Whether the user account is archived" + } + }, + "required": [ + "id", + "username", + "displayName", + "createdAt" + ], + "additionalProperties": false } diff --git a/services/ontology/schemas/vote.json b/services/ontology/schemas/vote.json index f9f0e9f16..493cbab6a 100644 --- a/services/ontology/schemas/vote.json +++ b/services/ontology/schemas/vote.json @@ -1,110 +1,161 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "schemaId": "660e8400-e29b-41d4-a716-446655440101", - "title": "Vote", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The unique identifier for the vote" - }, - "pollId": { - "type": "string", - "format": "uuid", - "description": "ID of the poll being voted on" - }, - "poll": { - "type": "string", - "format": "uuid", - "description": "Global ID of the poll (reference)" - }, - "userId": { - "type": "string", - "format": "uuid", - "description": "ID of the user who cast the vote" + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "660e8400-e29b-41d4-a716-446655440101", + "title": "Vote", + "domain": "governance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the vote" + }, + "pollId": { + "type": "string", + "format": "uuid", + "description": "ID of the poll being voted on" + }, + "poll": { + "type": "string", + "format": "uuid", + "description": "Global ID of the poll (reference)" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who cast the vote" + }, + "voterId": { + "type": "string", + "format": "uuid", + "description": "ID of the voter (user or delegate)" + }, + "data": { + "description": "Vote payload. Shape depends on the poll voting mode; must match the poll's mode.", + "oneOf": [ + { + "type": "object", + "description": "Normal vote: single or multiple option indices as strings.", + "properties": { + "mode": { + "const": "normal", + "description": "Must match poll voting mode" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Option indices selected (e.g. [\"0\", \"1\"])" + } + }, + "required": [ + "mode", + "options" + ], + "additionalProperties": false }, - "voterId": { - "type": "string", - "format": "uuid", - "description": "ID of the voter (user or delegate)" + { + "type": "object", + "description": "Point vote: option index → points.", + "properties": { + "mode": { + "const": "point", + "description": "Must match poll voting mode" + }, + "points": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Option index to points (e.g. { \"0\": 5, \"1\": 3 })" + } + }, + "required": [ + "mode", + "points" + ], + "additionalProperties": false }, - "data": { - "description": "Vote payload. Shape depends on the poll voting mode; must match the poll's mode.", - "oneOf": [ - { - "type": "object", - "description": "Normal vote: single or multiple option indices as strings.", - "properties": { - "mode": { "const": "normal", "description": "Must match poll voting mode" }, - "options": { - "type": "array", - "items": { "type": "string" }, - "description": "Option indices selected (e.g. [\"0\", \"1\"])" - } - }, - "required": ["mode", "options"], - "additionalProperties": false - }, - { - "type": "object", - "description": "Point vote: option index → points.", - "properties": { - "mode": { "const": "point", "description": "Must match poll voting mode" }, - "points": { - "type": "object", - "additionalProperties": { "type": "number" }, - "description": "Option index to points (e.g. { \"0\": 5, \"1\": 3 })" - } - }, - "required": ["mode", "points"], - "additionalProperties": false + { + "type": "object", + "description": "Rank vote: ordered choices; rank 1 = first choice.", + "properties": { + "mode": { + "const": "rank", + "description": "Must match poll voting mode" + }, + "rankings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "option": { + "type": "string", + "description": "Option index" + }, + "rank": { + "type": "integer", + "minimum": 1, + "description": "1 = first choice" + } }, - { - "type": "object", - "description": "Rank vote: ordered choices; rank 1 = first choice.", - "properties": { - "mode": { "const": "rank", "description": "Must match poll voting mode" }, - "rankings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "option": { "type": "string", "description": "Option index" }, - "rank": { "type": "integer", "minimum": 1, "description": "1 = first choice" } - }, - "required": ["option", "rank"] - }, - "description": "Ordered choices by rank" - } - }, - "required": ["mode", "rankings"], - "additionalProperties": false - }, - { - "type": "object", - "description": "Blind vote: commitment and proof.", - "properties": { - "mode": { "const": "blind", "description": "Must match poll voting mode" }, - "commitment": { "type": "string", "description": "Commitment to the vote" }, - "proof": { "type": "string", "description": "Proof binding commitment" } - }, - "required": ["mode", "commitment", "proof"], - "additionalProperties": false - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the vote was cast" + "required": [ + "option", + "rank" + ] + }, + "description": "Ordered choices by rank" + } + }, + "required": [ + "mode", + "rankings" + ], + "additionalProperties": false }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "When the vote was last updated" + { + "type": "object", + "description": "Blind vote: commitment and proof.", + "properties": { + "mode": { + "const": "blind", + "description": "Must match poll voting mode" + }, + "commitment": { + "type": "string", + "description": "Commitment to the vote" + }, + "proof": { + "type": "string", + "description": "Proof binding commitment" + } + }, + "required": [ + "mode", + "commitment", + "proof" + ], + "additionalProperties": false } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the vote was cast" }, - "required": ["id", "pollId", "voterId", "createdAt"], - "additionalProperties": false + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the vote was last updated" + } + }, + "required": [ + "id", + "pollId", + "voterId", + "createdAt" + ], + "additionalProperties": false } From e4d3fb832ceaf04a6bbd06b9e4a68d4b4ba85f5a Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 04/14] feat(ontology): serve domain list and per-domain schema lookup --- services/ontology/src/index.js | 81 +++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/services/ontology/src/index.js b/services/ontology/src/index.js index 222721cd8..cd914702d 100644 --- a/services/ontology/src/index.js +++ b/services/ontology/src/index.js @@ -16,10 +16,50 @@ app.use(express.json()); // Schema directory path const SCHEMAS_DIR = path.join(__dirname, '../schemas'); +// The domain list is published as an ordinary schema, so it is versioned, +// browsable and fetchable like every other type rather than living in a file +// of its own. +const DOMAIN_SCHEMA_TITLE = 'Domain'; // In-memory schema index let schemaIndex = new Map(); +// The domains every schema belongs to, and that platforms are granted access +// to one by one. Loaded once at boot alongside the schemas. +let domains = []; +let domainsById = new Map(); + +/** + * Reads the domain list out of the Domain schema's enum. Each permitted value + * carries its own title and description, which is the JSON Schema way to give + * an enum human-readable labels. + */ +function loadDomains() { + const schema = Array.from(schemaIndex.values()).find( + (s) => s.title === DOMAIN_SCHEMA_TITLE + ); + const options = schema?.properties?.id?.oneOf; + domains = Array.isArray(options) + ? options + .filter((o) => typeof o.const === 'string') + .map((o) => ({ + id: o.const, + label: o.title || o.const, + description: o.description || '' + })) + : []; + domainsById = new Map(domains.map((d) => [d.id, d])); + console.log( + `Loaded ${domains.length} domains from the ${DOMAIN_SCHEMA_TITLE} schema` + ); +} + +/** A schema's domain, resolved to its full record for display. */ +function domainOf(schema) { + if (!schema || !schema.domain) return null; + return domainsById.get(schema.domain) || { id: schema.domain, label: schema.domain, description: '' }; +} + // Load all schemas into memory async function loadSchemas() { try { @@ -39,6 +79,7 @@ async function loadSchemas() { } console.log(`Loaded ${schemaIndex.size} schemas`); + loadDomains(); } catch (error) { console.error('Error loading schemas:', error); throw error; @@ -49,14 +90,17 @@ async function loadSchemas() { function getSchemaList(q) { const list = Array.from(schemaIndex.entries()).map(([id, schema]) => ({ id, - title: schema.title == null ? '' : String(schema.title) + title: schema.title == null ? '' : String(schema.title), + domain: domainOf(schema) })); if (!q || typeof q !== 'string' || q.trim() === '') return list; const lower = q.toLowerCase().trim(); + // Searching a domain name finds everything in that domain. return list.filter( (s) => (s.title || '').toLowerCase().includes(lower) || - (s.id || '').toLowerCase().includes(lower) + (s.id || '').toLowerCase().includes(lower) || + (s.domain ? `${s.domain.id} ${s.domain.label}`.toLowerCase().includes(lower) : false) ); } @@ -82,7 +126,9 @@ app.get('/', async (req, res) => { res.render('index', { schemas, searchQuery, - selectedSchema + selectedSchema, + selectedDomain: domainOf(selectedSchema), + domains }); } catch (error) { console.error('Error rendering ontology viewer:', error); @@ -102,7 +148,9 @@ app.get('/schema/:uuid', async (req, res) => { res.render('index', { schemas, searchQuery: '', - selectedSchema + selectedSchema, + selectedDomain: domainOf(selectedSchema), + domains }); } catch (error) { console.error('Error rendering schema page:', error); @@ -131,7 +179,8 @@ app.get('/schemas', async (req, res) => { try { const schemas = Array.from(schemaIndex.entries()).map(([id, schema]) => ({ id, - title: schema.title + title: schema.title, + domain: schema.domain || null })); res.json(schemas); @@ -140,6 +189,28 @@ app.get('/schemas', async (req, res) => { } }); +// The domain list: what a platform can be granted access to, and what every +// schema is tagged with. Consumed by the Post Platforms Association. +app.get('/domains', async (req, res) => { + const schema = Array.from(schemaIndex.values()).find( + (s) => s.title === DOMAIN_SCHEMA_TITLE + ); + res.json({ schemaId: schema ? schema.schemaId : null, domains }); +}); + +// Which schemas fall under one domain — the practical question when deciding +// whether to grant a platform access to it. +app.get('/domains/:id/schemas', async (req, res) => { + const domain = domainsById.get(req.params.id); + if (!domain) { + return res.status(404).json({ error: 'Domain not found' }); + } + const schemas = Array.from(schemaIndex.entries()) + .filter(([, schema]) => schema.domain === domain.id) + .map(([id, schema]) => ({ id, title: schema.title })); + res.json({ domain, schemas }); +}); + // Start server async function startServer() { await ensureSchemasDirectory(); From 9eb044fc47ed9fd351919eecf1779cffa0b0a1fd Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 05/14] feat(ontology): show domains and enum values in the viewer --- services/ontology/views/index.ejs | 108 ++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/services/ontology/views/index.ejs b/services/ontology/views/index.ejs index edb7db38e..6b0cafe47 100644 --- a/services/ontology/views/index.ejs +++ b/services/ontology/views/index.ejs @@ -59,6 +59,34 @@ .schema-list .link-view:hover { text-decoration: underline; } .schema-list .link-raw { color: #6b7280; text-decoration: none; font-size: 0.875rem; } .schema-list .link-raw:hover { text-decoration: underline; } + .domain-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 999px; + background: #ede9fe; + color: #5b21b6; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + text-decoration: none; + } + .domain-badge:hover { background: #ddd6fe; } + .domain-badge.is-untagged { background: #f3f4f6; color: #6b7280; } + .domain-filters { margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: 0.375rem; } + .domain-filters .domain-badge { font-weight: 500; } + .domain-note { color: #6b7280; font-size: 0.875rem; margin: 0 0 0.5rem; } + .enum-values { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.375rem; } + .enum-value { + display: inline-flex; + align-items: baseline; + gap: 0.375rem; + padding: 0.125rem 0.5rem; + border: 1px solid #e5e7eb; + border-radius: 999px; + font-size: 0.75rem; + background: #fff; + } + .enum-value code { font-size: 0.7rem; color: #6b7280; } /* Drawer */ .drawer-overlay { @@ -141,6 +169,18 @@ +

Domains

+

+ Every schema belongs to one domain. Platforms are granted access a + domain at a time, so this is what a grant actually covers. +

+
+ All + <% domains.forEach(function(d) { %> + <%= d.label %> + <% }); %> +
+

Schemas

    <% if (schemas.length === 0) { %> @@ -149,6 +189,11 @@ <% schemas.forEach(function(s) { %>
  • <%= s.title %> + <% if (s.domain) { %> + <%= s.domain.label %> + <% } else { %> + No domain + <% } %> <%= s.id %> Raw JSON View @@ -167,6 +212,14 @@
    <% if (selectedSchema) { %> + <% if (selectedDomain) { %> +

    Domain: + <%= selectedDomain.label %> + <% if (selectedDomain.description) { %> +
    <%= selectedDomain.description %> + <% } %> +

    + <% } %>

    Schema ID: <%= selectedSchema.schemaId %>

    <% if (selectedSchema.required && selectedSchema.required.length) { %> @@ -183,15 +236,26 @@ <%= name %> - <% if (prop.oneOf) { %>one of (<%= prop.oneOf.length %> alternatives)<% } else if (prop.anyOf) { %>any of (<%= prop.anyOf.length %>)<% } else if (prop.allOf) { %>all of (<%= prop.allOf.length %>)<% } else { %><%= Array.isArray(prop.type) ? prop.type.join(' | ') : (prop.type || '-') %><%= prop.format ? ' (' + (prop.format) + ')' : '' %><% } %> + <% if (prop.oneOf && prop.oneOf.every(function(o){ return o.const !== undefined; })) { %>enum (<%= prop.oneOf.length %> values)<% } else if (prop.oneOf) { %>one of (<%= prop.oneOf.length %> alternatives)<% } else if (prop.anyOf) { %>any of (<%= prop.anyOf.length %>)<% } else if (prop.allOf) { %>all of (<%= prop.allOf.length %>)<% } else { %><%= Array.isArray(prop.type) ? prop.type.join(' | ') : (prop.type || '-') %><%= prop.format ? ' (' + (prop.format) + ')' : '' %><% } %> + + + <%= prop.description || '-' %> + <% if (prop.oneOf && prop.oneOf.every(function(o){ return o.const !== undefined; })) { %> +
    + <% prop.oneOf.forEach(function(o) { %> + + <%= o.title || o.const %><%= o.const %> + + <% }); %> +
    + <% } %> - <%= prop.description || '-' %> <% }); %> <% Object.entries(selectedSchema.properties).forEach(function([name, prop]) { %> - <% if (prop.oneOf && prop.oneOf.length) { %> + <% if (prop.oneOf && prop.oneOf.length && !prop.oneOf.every(function(o){ return o.const !== undefined; })) { %>

    <%= name %> — one of:

    <% prop.oneOf.forEach(function(alt, idx) { %> @@ -296,7 +360,22 @@ document.getElementById('drawerClose').addEventListener('click', closeDrawer); overlay.addEventListener('click', closeDrawer); + function isValueEnum(prop) { + return !!prop.oneOf && prop.oneOf.length > 0 && + prop.oneOf.every(function(o) { return o.const !== undefined; }); + } + function enumValuesHtml(prop) { + if (!isValueEnum(prop)) return ''; + var h = '
    '; + prop.oneOf.forEach(function(o) { + h += '' + + escapeHtml(o.title || String(o.const)) + + '' + escapeHtml(String(o.const)) + ''; + }); + return h + '
    '; + } function propTypeStr(prop) { + if (isValueEnum(prop)) return 'enum (' + prop.oneOf.length + ' values)'; if (prop.oneOf) return 'one of (' + prop.oneOf.length + ' alternatives)'; if (prop.anyOf) return 'any of (' + prop.anyOf.length + ')'; if (prop.allOf) return 'all of (' + prop.allOf.length + ')'; @@ -316,8 +395,27 @@ if (alt.required && alt.required.length) h += '

    Required: ' + escapeHtml(alt.required.join(', ')) + '

    '; return h; } + var DOMAINS = <%- JSON.stringify(domains) %>; + + function domainOf(id) { + for (var i = 0; i < DOMAINS.length; i++) { + if (DOMAINS[i].id === id) return DOMAINS[i]; + } + return id ? { id: id, label: id, description: '' } : null; + } + function renderSchemaDetail(schema) { - var html = '

    Schema ID: ' + escapeHtml(schema.schemaId) + '

    '; + var html = ''; + var domain = domainOf(schema.domain); + if (domain) { + html += '

    Domain: ' + escapeHtml(domain.label) + ''; + if (domain.description) { + html += '
    ' + escapeHtml(domain.description) + ''; + } + html += '

    '; + } + html += '

    Schema ID: ' + escapeHtml(schema.schemaId) + '

    '; html += ''; if (schema.required && schema.required.length) { html += '

    Required: ' + escapeHtml(schema.required.join(', ')) + '

    '; @@ -327,7 +425,7 @@ for (var propName in schema.properties) { var prop = schema.properties[propName]; var typeStr = propTypeStr(prop); - html += '' + escapeHtml(propName) + '' + escapeHtml(typeStr) + '' + escapeHtml(prop.description || '-') + ''; + html += '' + escapeHtml(propName) + '' + escapeHtml(typeStr) + '' + escapeHtml(prop.description || '-') + enumValuesHtml(prop) + ''; } html += ''; var labels = { oneOf: 'one of', anyOf: 'any of', allOf: 'all of' }; From 825f41247e605475c2328f6fbb2ddc3bc4acc21f Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:05 +0800 Subject: [PATCH 06/14] feat(ontology): add PlatformAccreditation schema --- .../schemas/platformAccreditation.json | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 services/ontology/schemas/platformAccreditation.json diff --git a/services/ontology/schemas/platformAccreditation.json b/services/ontology/schemas/platformAccreditation.json new file mode 100644 index 000000000..98cad9cbc --- /dev/null +++ b/services/ontology/schemas/platformAccreditation.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "e1749947-5a10-4973-b9fa-230d8714c36a", + "title": "PlatformAccreditation", + "domain": "governance", + "type": "object", + "description": "A decision issued by the Post Platforms Association (PPA) on a platform application for network access. The record is stored in the eVault of the platform it is about, with a public ACL, so it travels with that platform and anyone can read it. The association owns no vault of its own: its identity is a signing key, and the `jws` field carries the whole decision as a self-contained ES256 JWS that verifies against `issuerJwksUri` without trusting the eVault, the PPA app, or the platform holding it. Records are append-only and scoped to one platform version: the newest record for a given platform and version is the one in force, and a new version starts unaccredited.", + "properties": { + "accreditationId": { + "type": "string", + "minLength": 1, + "description": "Stable id for this decision; also the `jti` claim of `jws`" + }, + "platformEName": { + "type": "string", + "minLength": 1, + "description": "eName of the platform eVault the decision is about" + }, + "platformName": { + "type": "string", + "description": "The platform's stable slug, copied from its PlatformProfile at decision time" + }, + "platformVersion": { + "type": "string", + "minLength": 1, + "description": "The platform version this decision covers. A decision is valid for one version only: publishing a new version requires a new decision." + }, + "decision": { + "type": "string", + "enum": [ + "granted", + "denied" + ], + "description": "Whether access was granted or refused" + }, + "level": { + "type": [ + "string", + "null" + ], + "enum": [ + "L1", + "L2", + "L3", + "L4", + "L5", + null + ], + "description": "Access level granted; null when decision is 'denied'" + }, + "domains": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "description": "Domain ids the platform is granted access to, from the association domain list. Each ontology declares the domain it belongs to, so granting a domain grants that domain’s data. Empty when the decision is a denial." + }, + "statement": { + "type": "string", + "description": "Free-text reasoning from the reviewer, shown to the applicant and covered by the signature" + }, + "reviewedByEName": { + "type": "string", + "minLength": 1, + "description": "eName of the PPA admin who made the decision" + }, + "issuerJwksUri": { + "type": "string", + "format": "uri", + "description": "Where to fetch the JWK set that verifies `jws` — the association is identified by this key, not by an eVault." + }, + "submissionEnvelopeId": { + "type": "string", + "description": "MetaEnvelope id of the PlatformProfile submission this decision reviewed" + }, + "status": { + "type": "string", + "enum": [ + "active", + "superseded" + ], + "description": "'superseded' marks a decision replaced by a later one for the same platform" + }, + "jws": { + "type": "string", + "minLength": 1, + "description": "Compact ES256 JWS over the decision, verifiable against issuerJwksUri" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "accreditationId", + "platformEName", + "platformVersion", + "decision", + "domains", + "reviewedByEName", + "issuerJwksUri", + "jws", + "createdAt" + ], + "additionalProperties": false +} From 4bbfd2c52ee8074236d1d85498b36b154f4a4a72 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 07/14] feat(ppa): scaffold the association app on port 4210 --- pnpm-lock.yaml | 227 +++++++++++++++++++--------- services/ppa/package.json | 36 +++++ services/ppa/src/app.css | 135 +++++++++++++++++ services/ppa/src/app.d.ts | 10 ++ services/ppa/src/app.html | 19 +++ services/ppa/src/lib/levels.ts | 15 ++ services/ppa/src/lib/types.ts | 12 ++ services/ppa/src/svelte-qrcode.d.ts | 20 +++ services/ppa/svelte.config.js | 15 ++ services/ppa/tsconfig.json | 14 ++ services/ppa/vite.config.ts | 7 + 11 files changed, 437 insertions(+), 73 deletions(-) create mode 100644 services/ppa/package.json create mode 100644 services/ppa/src/app.css create mode 100644 services/ppa/src/app.d.ts create mode 100644 services/ppa/src/app.html create mode 100644 services/ppa/src/lib/levels.ts create mode 100644 services/ppa/src/lib/types.ts create mode 100644 services/ppa/src/svelte-qrcode.d.ts create mode 100644 services/ppa/svelte.config.js create mode 100644 services/ppa/tsconfig.json create mode 100644 services/ppa/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b478bff..c38cb2516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3272,7 +3272,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) @@ -3293,7 +3293,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) @@ -4081,6 +4081,61 @@ importers: specifier: ^3.0.2 version: 3.1.14 + services/ppa: + dependencies: + axios: + specifier: ^1.12.2 + version: 1.13.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + graphql-request: + specifier: ^7.3.1 + version: 7.4.0(graphql@16.13.1) + jose: + specifier: ^5.2.2 + version: 5.10.0 + signature-validator: + specifier: workspace:* + version: link:../../infrastructure/signature-validator + 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@20.19.26)(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@20.19.26)(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@20.19.26)(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@20.19.26)(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@20.19.26)(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@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + 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 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@20.19.26)(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/search-engine: dependencies: axios: @@ -5898,11 +5953,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -13614,6 +13669,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -28137,6 +28193,14 @@ snapshots: dependencies: acorn: 8.16.0 + '@sveltejs/adapter-node@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@20.19.26)(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@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) + '@rollup/plugin-json': 6.1.0(rollup@4.59.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) + '@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@20.19.26)(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@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + rollup: 4.59.0 + '@sveltejs/adapter-node@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.8.2)(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)))': dependencies: '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) @@ -28169,6 +28233,27 @@ snapshots: dependencies: '@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.8.2)(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@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@20.19.26)(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@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.4 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.53.11 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + typescript: 5.9.3 + '@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@22.19.15)(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.6.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 @@ -28285,6 +28370,15 @@ snapshots: transitivePeerDependencies: - typescript + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(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)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + debug: 4.4.3(supports-color@5.5.0) + svelte: 5.53.11 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(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)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -28317,6 +28411,19 @@ snapshots: svelte: 5.53.11 vite: 7.3.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@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(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)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + debug: 4.4.3(supports-color@5.5.0) + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.53.11 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.2(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + transitivePeerDependencies: + - supports-color + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(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)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -28561,6 +28668,13 @@ snapshots: tailwindcss: 4.2.1 vite: 5.4.21(@types/node@20.19.26)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0) + '@tailwindcss/vite@4.2.1(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 6.4.1(@types/node@20.19.26)(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@4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.1 @@ -32639,9 +32753,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 @@ -32649,9 +32763,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: @@ -33102,8 +33216,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)) @@ -33166,21 +33280,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 @@ -33223,17 +33322,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: @@ -33273,35 +33361,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 @@ -33313,7 +33372,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 @@ -34053,7 +34112,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) @@ -34949,9 +35008,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: {} @@ -39323,12 +39382,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 @@ -42249,6 +42308,24 @@ snapshots: sass: 1.98.0 terser: 5.46.0 + vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.26 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + sass: 1.98.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -42321,6 +42398,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitefu@1.1.2(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu@1.1.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) diff --git a/services/ppa/package.json b/services/ppa/package.json new file mode 100644 index 000000000..dddb5b42f --- /dev/null +++ b/services/ppa/package.json @@ -0,0 +1,36 @@ +{ + "name": "ppa", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --host --port 4210 --strictPort", + "build": "vite build", + "preview": "vite preview --port 4210 --strictPort", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "generate-jwk": "node scripts/generate-ppa-jwk.cjs", + "seed:submission": "tsx scripts/seed-submission.ts" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^20.11.24", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "tsx": "^4.19.2", + "typescript": "^5.0.0", + "vite": "^6.2.6" + }, + "dependencies": { + "axios": "^1.12.2", + "dotenv": "^16.4.5", + "graphql-request": "^7.3.1", + "jose": "^5.2.2", + "signature-validator": "workspace:*", + "svelte-qrcode": "^1.0.1" + } +} diff --git a/services/ppa/src/app.css b/services/ppa/src/app.css new file mode 100644 index 000000000..60b62eae1 --- /dev/null +++ b/services/ppa/src/app.css @@ -0,0 +1,135 @@ +@import "tailwindcss"; + +/** + * Design tokens taken from w3alliance.net: a light, institutional palette on + * white, violet #8869ff as the single accent, deep navy #1d2636 for headings + * and #333 for body copy, Inter throughout, and generously rounded surfaces + * with soft shadows rather than hard borders. + */ +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; + + --color-canvas: #f6f5fb; + --color-surface: #ffffff; + --color-ink: #1d2636; + --color-body: #333333; + --color-muted: #6b7280; + --color-faint: #9aa1ad; + --color-line: #e9e7f2; + + --color-brand: #8869ff; + --color-brand-strong: #6f4dff; + --color-brand-tint: #ddd3ff; + --color-brand-wash: #f4f1ff; + --color-info: #0099ff; + + --color-positive: #0f9d68; + --color-positive-wash: #e7f6ef; + --color-caution: #b26a00; + --color-caution-wash: #fdf3e3; + --color-negative: #d1443c; + --color-negative-wash: #fdeceb; + + --radius-card: 1.5rem; + --radius-panel: 2rem; + + --shadow-soft: 0 4px 20px rgb(29 38 54 / 0.06); + --shadow-lift: 0 12px 32px rgb(29 38 54 / 0.10); +} + +html { + color-scheme: light; +} + +body { + background: var(--color-canvas); + color: var(--color-body); + -webkit-font-smoothing: antialiased; +} + +@layer components { + .card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-card); + box-shadow: var(--shadow-soft); + } + + /* Section label above a heading — small, tracked, brand-coloured. */ + .eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-brand); + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 999px; + padding: 0.6875rem 1.375rem; + font-size: 0.875rem; + font-weight: 600; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; + } + + .btn-primary { + background: var(--color-brand); + color: #fff; + } + .btn-primary:hover:not(:disabled) { + background: var(--color-brand-strong); + } + .btn-primary:disabled { + opacity: 0.55; + } + + .btn-quiet { + border: 1px solid var(--color-line); + background: var(--color-surface); + color: var(--color-ink); + } + .btn-quiet:hover { + border-color: var(--color-brand-tint); + color: var(--color-brand); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: 999px; + padding: 0.3125rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .field { + width: 100%; + border: 1px solid var(--color-line); + border-radius: 1rem; + background: var(--color-surface); + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: var(--color-body); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + } + .field:focus { + border-color: var(--color-brand); + box-shadow: 0 0 0 4px var(--color-brand-wash); + } + + /* Long opaque strings (eNames, JWS) that must not blow out the layout. */ + .mono-block { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.75rem; + line-height: 1.5; + overflow-wrap: anywhere; + color: var(--color-muted); + } +} diff --git a/services/ppa/src/app.d.ts b/services/ppa/src/app.d.ts new file mode 100644 index 000000000..4b0fb361a --- /dev/null +++ b/services/ppa/src/app.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace App { + interface Locals { + /** The signed-in PPA admin, or null when unauthenticated. */ + user: { ename: string } | null; + } + } +} + +export {}; diff --git a/services/ppa/src/app.html b/services/ppa/src/app.html new file mode 100644 index 000000000..432ce7454 --- /dev/null +++ b/services/ppa/src/app.html @@ -0,0 +1,19 @@ + + + + + + + + + + Post Platforms Association + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts new file mode 100644 index 000000000..f256c8198 --- /dev/null +++ b/services/ppa/src/lib/levels.ts @@ -0,0 +1,15 @@ +/** + * Shared between the decision form and the server that signs it, so this + * lives outside $lib/server — SvelteKit refuses to bundle server-only modules + * into a component. + */ + +export const ACCESS_LEVELS = ["L1", "L2", "L3", "L4", "L5"] as const; +export type AccessLevel = (typeof ACCESS_LEVELS)[number]; + +export function isAccessLevel(value: unknown): value is AccessLevel { + return ( + typeof value === "string" && + (ACCESS_LEVELS as readonly string[]).includes(value) + ); +} diff --git a/services/ppa/src/lib/types.ts b/services/ppa/src/lib/types.ts new file mode 100644 index 000000000..e8a13bac7 --- /dev/null +++ b/services/ppa/src/lib/types.ts @@ -0,0 +1,12 @@ +/** + * Types shared between server code and components. Lives outside $lib/server + * because SvelteKit refuses to pull a server-only module into a component, + * type-only import or not. + */ + +/** A domain of data, as published by the ontology service. */ +export interface Domain { + id: string; + label: string; + description: string; +} diff --git a/services/ppa/src/svelte-qrcode.d.ts b/services/ppa/src/svelte-qrcode.d.ts new file mode 100644 index 000000000..cb853dae7 --- /dev/null +++ b/services/ppa/src/svelte-qrcode.d.ts @@ -0,0 +1,20 @@ +/** + * svelte-qrcode ships no type declarations — its package exports only the + * `svelte` condition pointing at raw component source. Declare the props we + * use so `svelte-check` can see the component. + */ +declare module "svelte-qrcode" { + import type { Component } from "svelte"; + + const QrCode: Component<{ + value?: string; + size?: string | number; + color?: string; + background?: string; + padding?: number; + errorCorrection?: "L" | "M" | "Q" | "H"; + className?: string; + }>; + + export default QrCode; +} diff --git a/services/ppa/svelte.config.js b/services/ppa/svelte.config.js new file mode 100644 index 000000000..0f8960e9c --- /dev/null +++ b/services/ppa/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + // Every service in this monorepo reads the repo-root .env. + env: { + dir: "../../", + }, + }, +}; + +export default config; diff --git a/services/ppa/tsconfig.json b/services/ppa/tsconfig.json new file mode 100644 index 000000000..104691d2d --- /dev/null +++ b/services/ppa/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/ppa/vite.config.ts b/services/ppa/vite.config.ts new file mode 100644 index 000000000..deb417265 --- /dev/null +++ b/services/ppa/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 4f229a6330c7b8ea02fc5ef661b460fa127cfbc5 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 08/14] feat(ppa): add shell and shared components --- services/ppa/src/lib/DomainChips.svelte | 25 ++++++++ services/ppa/src/lib/Logo.svelte | 23 ++++++++ services/ppa/src/lib/PlatformMark.svelte | 42 +++++++++++++ services/ppa/src/lib/StatusPill.svelte | 26 ++++++++ services/ppa/src/routes/+layout.server.ts | 5 ++ services/ppa/src/routes/+layout.svelte | 72 +++++++++++++++++++++++ 6 files changed, 193 insertions(+) create mode 100644 services/ppa/src/lib/DomainChips.svelte create mode 100644 services/ppa/src/lib/Logo.svelte create mode 100644 services/ppa/src/lib/PlatformMark.svelte create mode 100644 services/ppa/src/lib/StatusPill.svelte create mode 100644 services/ppa/src/routes/+layout.server.ts create mode 100644 services/ppa/src/routes/+layout.svelte diff --git a/services/ppa/src/lib/DomainChips.svelte b/services/ppa/src/lib/DomainChips.svelte new file mode 100644 index 000000000..ea924029e --- /dev/null +++ b/services/ppa/src/lib/DomainChips.svelte @@ -0,0 +1,25 @@ + + +{#if shown.length === 0} + {empty} +{:else} + + {#each shown as label (label)} + {label} + {/each} + +{/if} diff --git a/services/ppa/src/lib/Logo.svelte b/services/ppa/src/lib/Logo.svelte new file mode 100644 index 000000000..6d0aeab38 --- /dev/null +++ b/services/ppa/src/lib/Logo.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/services/ppa/src/lib/PlatformMark.svelte b/services/ppa/src/lib/PlatformMark.svelte new file mode 100644 index 000000000..06b438257 --- /dev/null +++ b/services/ppa/src/lib/PlatformMark.svelte @@ -0,0 +1,42 @@ + + +
    + + + {#if showImage} + (failedUrl = logoUrl)} + /> + {/if} +
    diff --git a/services/ppa/src/lib/StatusPill.svelte b/services/ppa/src/lib/StatusPill.svelte new file mode 100644 index 000000000..caa11bdc2 --- /dev/null +++ b/services/ppa/src/lib/StatusPill.svelte @@ -0,0 +1,26 @@ + + +{#if decision === "granted"} + + + {level ?? "Granted"} + +{:else if decision === "denied"} + + + Denied + +{:else} + + + Awaiting review + +{/if} diff --git a/services/ppa/src/routes/+layout.server.ts b/services/ppa/src/routes/+layout.server.ts new file mode 100644 index 000000000..3735504b6 --- /dev/null +++ b/services/ppa/src/routes/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { user: locals.user }; +}; diff --git a/services/ppa/src/routes/+layout.svelte b/services/ppa/src/routes/+layout.svelte new file mode 100644 index 000000000..fd9d729be --- /dev/null +++ b/services/ppa/src/routes/+layout.svelte @@ -0,0 +1,72 @@ + + +
    + {#if data.user} +
    +
    + + + + Post Platforms + + Association + + + + + + +
    + +
    + + +
    +
    +
    + {/if} + +
    + {@render children()} +
    +
    From 13aaaeda627d3f987e04624397b0cc97b139cc02 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 09/14] feat(ppa): add W3DS login gated by an eName whitelist --- services/ppa/src/hooks.server.ts | 107 +++++++++++++ services/ppa/src/lib/server/allowlist.ts | 78 ++++++++++ services/ppa/src/lib/server/env.ts | 85 +++++++++++ services/ppa/src/lib/server/token.ts | 54 +++++++ services/ppa/src/lib/server/w3ds.ts | 104 +++++++++++++ services/ppa/src/routes/api/auth/+server.ts | 31 ++++ .../ppa/src/routes/api/auth/logout/+server.ts | 8 + .../ppa/src/routes/api/auth/offer/+server.ts | 7 + .../api/auth/session/[session]/+server.ts | 48 ++++++ services/ppa/src/routes/login/+page.svelte | 143 ++++++++++++++++++ 10 files changed, 665 insertions(+) create mode 100644 services/ppa/src/hooks.server.ts create mode 100644 services/ppa/src/lib/server/allowlist.ts create mode 100644 services/ppa/src/lib/server/env.ts create mode 100644 services/ppa/src/lib/server/token.ts create mode 100644 services/ppa/src/lib/server/w3ds.ts create mode 100644 services/ppa/src/routes/api/auth/+server.ts create mode 100644 services/ppa/src/routes/api/auth/logout/+server.ts create mode 100644 services/ppa/src/routes/api/auth/offer/+server.ts create mode 100644 services/ppa/src/routes/api/auth/session/[session]/+server.ts create mode 100644 services/ppa/src/routes/login/+page.svelte diff --git a/services/ppa/src/hooks.server.ts b/services/ppa/src/hooks.server.ts new file mode 100644 index 000000000..f5829a05e --- /dev/null +++ b/services/ppa/src/hooks.server.ts @@ -0,0 +1,107 @@ +import { json, redirect, type Handle } from "@sveltejs/kit"; +import { isAdminEName } from "$lib/server/allowlist"; +import { + AUTH_COOKIE_NAME, + authCookieOptions, + verifyAuthToken, +} from "$lib/server/token"; + +/** + * Everything behind the admin whitelist by default. Only the login page, the + * auth endpoints the wallet talks to, and the JWK set are public — the JWKS in + * particular must stay reachable unauthenticated or nobody outside the PPA can + * verify a statement it issued. + */ + +const PUBLIC_PATHS = new Set(["/login"]); + +function isPublicPath(pathname: string): boolean { + if (PUBLIC_PATHS.has(pathname)) return true; + if (pathname.startsWith("/api/auth")) return true; + if (pathname.startsWith("/.well-known/")) return true; + if (pathname.startsWith("/_app")) return true; + if (pathname === "/favicon.ico") return true; + return false; +} + +/** + * The eID wallet POSTs the signed session from its own webview origin with a + * JSON content type, which makes it a preflighted cross-origin request. Without + * these headers the browser never sends the POST at all and the wallet reports + * a generic authentication failure with nothing reaching us. + * + * `Access-Control-Allow-Private-Network` is what lets a webview on the phone + * reach this app on a LAN address at all — Chrome blocks public-to-private + * requests without it. Same handling as infrastructure/control-panel. + */ +function withCorsHeaders(response: Response): Response { + response.headers.set("Access-Control-Allow-Origin", "*"); + response.headers.set( + "Access-Control-Allow-Methods", + "GET, POST, OPTIONS", + ); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-ENAME, Accept", + ); + response.headers.set("Access-Control-Max-Age", "86400"); + response.headers.set("Access-Control-Allow-Private-Network", "true"); + return response; +} + +export const handle: Handle = async ({ event, resolve }) => { + const pathname = event.url.pathname; + + // Answer the preflight before any auth work — it carries no credentials. + if (event.request.method === "OPTIONS") { + if ( + event.request.headers.get("access-control-request-private-network") === + "true" + ) { + console.info("[ppa/auth] private-network preflight", { pathname }); + } + return withCorsHeaders(new Response(null, { status: 204 })); + } + + if (pathname.startsWith("/api/auth")) { + console.info("[ppa/auth] incoming", { + method: event.request.method, + pathname, + origin: event.request.headers.get("origin"), + contentType: event.request.headers.get("content-type"), + }); + } + + const token = event.cookies.get(AUTH_COOKIE_NAME); + const auth = token ? await verifyAuthToken(token) : null; + + // The allowlist is re-checked on every request rather than trusted from + // the session: dropping an eName from the whitelist must revoke it now, + // not whenever their week-long cookie happens to expire. The allowlist is + // mtime-cached, so this costs nothing per request. + if (auth && !(await isAdminEName(auth.ename))) { + console.warn( + "[ppa/auth] session presented by a no-longer-whitelisted eName:", + auth.ename, + ); + event.cookies.delete(AUTH_COOKIE_NAME, authCookieOptions(event.url)); + event.locals.user = null; + } else { + event.locals.user = auth ? { ename: auth.ename } : null; + } + + if (!event.locals.user && !isPublicPath(pathname)) { + if (pathname.startsWith("/api/")) { + return withCorsHeaders( + json({ error: "Unauthorized" }, { status: 401 }), + ); + } + throw redirect(302, "/login"); + } + + if (event.locals.user && pathname === "/login") { + throw redirect(302, "/"); + } + + return withCorsHeaders(await resolve(event)); +}; diff --git a/services/ppa/src/lib/server/allowlist.ts b/services/ppa/src/lib/server/allowlist.ts new file mode 100644 index 000000000..ffebfbda1 --- /dev/null +++ b/services/ppa/src/lib/server/allowlist.ts @@ -0,0 +1,78 @@ +import { readFile, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { adminEnamesCsv, adminEnamesFile } from "./env"; + +/** + * The whitelist of eNames allowed to act as PPA admins. Sourced from a JSON + * file (hot-reloaded on mtime change, so an eName can be added without a + * restart) unioned with an optional PPA_ADMIN_ENAMES csv for container + * deployments where mounting a file is awkward. + * + * Mirrors infrastructure/control-panel/src/lib/server/auth/allowlist.ts. + */ + +type AllowlistData = { + admins?: string[]; +}; + +let cachedPath: string | null = null; +let cachedMtimeMs = -1; +let cachedFileAdmins = new Set(); + +export function normalizeEName(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return ""; + return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; +} + +function getAllowlistPath(): string { + return resolve(process.cwd(), adminEnamesFile()); +} + +function csvAdmins(): Set { + return new Set( + adminEnamesCsv().split(",").map(normalizeEName).filter(Boolean), + ); +} + +async function fileAdmins(): Promise> { + const allowlistPath = getAllowlistPath(); + + try { + const fileStat = await stat(allowlistPath); + const shouldRefresh = + allowlistPath !== cachedPath || fileStat.mtimeMs !== cachedMtimeMs; + + if (!shouldRefresh) return cachedFileAdmins; + + const raw = await readFile(allowlistPath, "utf8"); + const parsed = JSON.parse(raw) as AllowlistData; + const admins = Array.isArray(parsed.admins) ? parsed.admins : []; + + cachedPath = allowlistPath; + cachedMtimeMs = fileStat.mtimeMs; + cachedFileAdmins = new Set(admins.map(normalizeEName).filter(Boolean)); + + return cachedFileAdmins; + } catch (error) { + console.error( + `[ppa/allowlist] failed loading admin allowlist from ${allowlistPath}:`, + error, + ); + cachedPath = allowlistPath; + cachedMtimeMs = -1; + cachedFileAdmins = new Set(); + return cachedFileAdmins; + } +} + +export async function getAdminAllowlist(): Promise> { + const fromFile = await fileAdmins(); + return new Set([...fromFile, ...csvAdmins()]); +} + +export async function isAdminEName(ename: string): Promise { + const normalized = normalizeEName(ename); + if (!normalized) return false; + return (await getAdminAllowlist()).has(normalized); +} diff --git a/services/ppa/src/lib/server/env.ts b/services/ppa/src/lib/server/env.ts new file mode 100644 index 000000000..2630d1aca --- /dev/null +++ b/services/ppa/src/lib/server/env.ts @@ -0,0 +1,85 @@ +import path from "node:path"; +import { config as loadEnv } from "dotenv"; +import { env } from "$env/dynamic/private"; + +/** + * Every value the PPA reads out of the repo-root .env, resolved in one place. + * + * Deliberately does NOT use `$env/dynamic/public`. Several shared variables in + * this monorepo carry SvelteKit's PUBLIC_ prefix, and importing that module + * serialises the whole public env block — every service URL in the ecosystem, + * credentials included — into the HTML of every page, signed in or not. Nothing + * here is needed in the browser, so the root .env is loaded directly and all + * configuration stays on the server. + */ + +// cwd is services/ppa under both `vite dev` and `node build/index.js`. +loadEnv({ path: path.resolve(process.cwd(), "../../.env") }); + +function raw(name: string): string { + return (env[name] ?? process.env[name] ?? "").trim(); +} + +/** Public base URL of this app — the w3ds://auth callback and JWS issuer. */ +export function publicUrl(): string { + return raw("PPA_PUBLIC_URL") || raw("PUBLIC_PPA_URL") || "http://localhost:4210"; +} + +export function registryUrl(): string { + const url = raw("REGISTRY_URL") || raw("PUBLIC_REGISTRY_URL"); + if (!url) throw new Error("PUBLIC_REGISTRY_URL is required"); + return url; +} + +export function provisionerUrl(): string { + return raw("PUBLIC_PROVISIONER_URL") || "http://localhost:3001"; +} + +/** Ontology service — publisher of the schemas and the domain list. */ +export function ontologyUrl(): string { + return raw("PUBLIC_ONTOLOGY_URL") || "https://ontology.w3ds.metastate.foundation"; +} + +export function awarenessUrl(): string { + return raw("AWARENESS_SERVICE_URL") || "http://localhost:4100"; +} + +/** PPA's own AaaS consumer key, falling back to the shared one. */ +export function awarenessApiKey(): string { + return raw("PPA_AWARENESS_API_KEY") || raw("AWARENESS_API_KEY"); +} + +export function jwtSecret(): string { + return raw("PPA_JWT_SECRET") || "ppa-dev-secret"; +} + +export function signingJwk(): string { + return raw("PPA_SIGNING_JWK"); +} + +export function adminEnamesFile(): string { + return raw("PPA_ADMIN_ENAMES_FILE") || "config/admin-enames.json"; +} + +export function adminEnamesCsv(): string { + return raw("PPA_ADMIN_ENAMES"); +} + +/** Slug of the messenger platform to look up in AaaS for the contact button. */ +export function messengerPlatformName(): string { + return raw("PPA_MESSENGER_PLATFORM_NAME") || "meshenger"; +} + +/** + * Path on the messenger that opens a conversation with one person, with + * `{ename}` substituted. Only used when the messenger declares no handle for + * the User ontology — a declared handle always wins, since that is the + * messenger describing itself rather than us assuming. + */ +export function messengerContactPath(): string { + return raw("PPA_MESSENGER_CONTACT_PATH") || "/contacts/{ename}"; +} + +export function demoVerificationCode(): string { + return raw("DEMO_VERIFICATION_CODE"); +} diff --git a/services/ppa/src/lib/server/token.ts b/services/ppa/src/lib/server/token.ts new file mode 100644 index 000000000..f476bed38 --- /dev/null +++ b/services/ppa/src/lib/server/token.ts @@ -0,0 +1,54 @@ +import { SignJWT, jwtVerify } from "jose"; +import { jwtSecret } from "./env"; + +/** + * The PPA admin session: an HS256 JWT held in an httpOnly cookie. Distinct + * from the ES256 accreditation key in jwt.ts — this one only says "you are + * logged in", it never leaves the app. + */ + +export const AUTH_COOKIE_NAME = "ppa_auth"; +const SESSION_TTL = "7d"; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +/** + * Cookie attributes for the admin session. + * + * `secure` follows the scheme actually in use rather than SvelteKit's default, + * which sets Secure for any host that is not localhost. Reached over a LAN + * address on plain HTTP — how this app is used when signing in from a phone — + * that default makes the browser discard the cookie without a word, so the + * login succeeds and the session never sticks. Over HTTPS this is still Secure. + */ +export function authCookieOptions(url: URL) { + return { + path: "/", + httpOnly: true, + sameSite: "lax" as const, + secure: url.protocol === "https:", + maxAge: SESSION_TTL_SECONDS, + }; +} + +function secret(): Uint8Array { + return new TextEncoder().encode(jwtSecret()); +} + +export async function signAuthToken(ename: string): Promise { + return new SignJWT({ ename }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(SESSION_TTL) + .sign(secret()); +} + +export async function verifyAuthToken( + token: string, +): Promise<{ ename: string } | null> { + try { + const { payload } = await jwtVerify(token, secret()); + return typeof payload.ename === "string" ? { ename: payload.ename } : null; + } catch { + return null; + } +} diff --git a/services/ppa/src/lib/server/w3ds.ts b/services/ppa/src/lib/server/w3ds.ts new file mode 100644 index 000000000..a3df72556 --- /dev/null +++ b/services/ppa/src/lib/server/w3ds.ts @@ -0,0 +1,104 @@ +import { randomUUID } from "node:crypto"; +// Import the TS source rather than the package main: the published dist is +// CommonJS, which rollup cannot statically analyse for named exports when +// bundling the SSR build. Same workaround as platforms/enotary. +import { verifySignature } from "signature-validator/src/index"; +import { publicUrl, registryUrl } from "./env"; + +/** + * w3ds://auth login. The eID wallet signs a session id we generated; we verify + * that signature against the registry and hand the session back as + * authenticated. Pending sessions are held in memory with a short TTL, so a + * restart simply invalidates any login mid-flight. + * + * Mirrors services/awareness-service/api/src/services/W3dsAuthService.ts. + */ + +interface PendingSession { + createdAt: number; + ename?: string; + status: "pending" | "authenticated"; +} + +const SESSION_TTL_MS = 10 * 60 * 1000; + +/** + * Kept on globalThis rather than in module scope on purpose. The offer, the + * wallet's callback and the browser's poll are three separate requests, and + * Vite's dev SSR can hand different request entry points their own instance of + * this module — which silently splits the map, so a login verifies but the + * page polling for it never sees the result. Anchoring the store outside the + * module graph makes the three requests share one map, and also keeps logins + * mid-flight alive across an HMR reload. + */ +const STORE = Symbol.for("ppa.w3ds.sessions"); +const globalStore = globalThis as typeof globalThis & { + [STORE]?: Map; +}; +const sessions: Map = (globalStore[STORE] ??= new Map()); + +function gc(): void { + const now = Date.now(); + for (const [id, s] of sessions) { + if (now - s.createdAt > SESSION_TTL_MS) sessions.delete(id); + } +} + +/** Builds the w3ds://auth offer the login page renders as a QR code. */ +export function createOffer(): { uri: string; session: string } { + gc(); + const session = randomUUID(); + sessions.set(session, { createdAt: Date.now(), status: "pending" }); + const redirect = new URL("/api/auth", publicUrl()).toString(); + const uri = `w3ds://auth?redirect=${redirect}&session=${session}&platform=ppa`; + return { uri, session }; +} + +/** Wallet callback: verify the signature over the session id. */ +export async function completeLogin( + ename: string, + session: string, + signature: string, +): Promise<{ ok: boolean; error?: string }> { + const pending = sessions.get(session); + if (!pending) return { ok: false, error: "unknown or expired session" }; + + const result = await verifySignature({ + eName: ename, + signature, + payload: session, + registryBaseUrl: registryUrl(), + }); + if (!result.valid) { + return { ok: false, error: result.error ?? "invalid signature" }; + } + + pending.ename = ename; + pending.status = "authenticated"; + console.info("[ppa/auth] session authenticated for", ename); + return { ok: true }; +} + +/** + * Polled by the login page. Returns the authenticated eName exactly once — + * the caller is responsible for the allowlist check and cookie minting. + * + * "unknown" is reported separately from "pending" so a QR that has expired (or + * was issued by a previous process) tells the page to start over instead of + * polling forever, and so a store that is not shared across requests shows up + * immediately rather than looking like a login that never completes. + */ +export function pollSession( + session: string, +): + | { status: "pending" } + | { status: "unknown" } + | { status: "authenticated"; ename: string } { + const pending = sessions.get(session); + if (!pending) return { status: "unknown" }; + if (pending.status === "authenticated" && pending.ename) { + sessions.delete(session); + return { status: "authenticated", ename: pending.ename }; + } + return { status: "pending" }; +} diff --git a/services/ppa/src/routes/api/auth/+server.ts b/services/ppa/src/routes/api/auth/+server.ts new file mode 100644 index 000000000..2272af3cf --- /dev/null +++ b/services/ppa/src/routes/api/auth/+server.ts @@ -0,0 +1,31 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { completeLogin } from "$lib/server/w3ds"; + +/** + * Wallet callback. Verifies the signature over the session id only — the + * admin whitelist is enforced when the browser polls for the session, so an + * unauthorised signature and an invalid one are indistinguishable here. + */ +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const ename = body?.w3id ?? body?.ename; + const session = body?.session; + const signature = body?.signature; + + if (!ename || !session || !signature) { + return json( + { error: "w3id, session and signature are required" }, + { status: 400 }, + ); + } + + const result = await completeLogin(ename, session, signature); + if (!result.ok) { + // Log the detail, return a generic message: a caller must not be able + // to tell "no such session" from "bad signature". + console.warn("[ppa/auth] login rejected for", ename, "-", result.error); + return json({ error: "Authentication failed" }, { status: 401 }); + } + return json({ ok: true }); +}; diff --git a/services/ppa/src/routes/api/auth/logout/+server.ts b/services/ppa/src/routes/api/auth/logout/+server.ts new file mode 100644 index 000000000..05167d076 --- /dev/null +++ b/services/ppa/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,8 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { AUTH_COOKIE_NAME, authCookieOptions } from "$lib/server/token"; + +export const POST: RequestHandler = async ({ cookies, url }) => { + cookies.delete(AUTH_COOKIE_NAME, authCookieOptions(url)); + return json({ ok: true }); +}; diff --git a/services/ppa/src/routes/api/auth/offer/+server.ts b/services/ppa/src/routes/api/auth/offer/+server.ts new file mode 100644 index 000000000..f68c24f21 --- /dev/null +++ b/services/ppa/src/routes/api/auth/offer/+server.ts @@ -0,0 +1,7 @@ +import { json } from "@sveltejs/kit"; +import { createOffer } from "$lib/server/w3ds"; + +/** Starts a login and hands back a w3ds://auth offer for the QR code. */ +export async function POST() { + return json(createOffer()); +} diff --git a/services/ppa/src/routes/api/auth/session/[session]/+server.ts b/services/ppa/src/routes/api/auth/session/[session]/+server.ts new file mode 100644 index 000000000..4875e02c1 --- /dev/null +++ b/services/ppa/src/routes/api/auth/session/[session]/+server.ts @@ -0,0 +1,48 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { isAdminEName, normalizeEName } from "$lib/server/allowlist"; +import { + AUTH_COOKIE_NAME, + authCookieOptions, + signAuthToken, +} from "$lib/server/token"; +import { pollSession } from "$lib/server/w3ds"; + +/** + * Polled by the login page. The whitelist gate lives here: a valid signature + * from an eName that is not an approved PPA admin gets 403 and no cookie. + */ +export const GET: RequestHandler = async ({ params, cookies, url }) => { + const result = pollSession(params.session); + if (result.status === "unknown") { + return json( + { + status: "expired", + error: "This sign-in request expired. Start again.", + }, + { status: 410 }, + ); + } + if (result.status !== "authenticated") { + return json({ status: "pending" }); + } + + const ename = normalizeEName(result.ename); + if (!(await isAdminEName(ename))) { + return json( + { + status: "forbidden", + error: "You're not approved to review submissions.", + }, + { status: 403 }, + ); + } + + cookies.set( + AUTH_COOKIE_NAME, + await signAuthToken(ename), + authCookieOptions(url), + ); + + return json({ status: "authenticated", ename }); +}; diff --git a/services/ppa/src/routes/login/+page.svelte b/services/ppa/src/routes/login/+page.svelte new file mode 100644 index 000000000..ebf2415c9 --- /dev/null +++ b/services/ppa/src/routes/login/+page.svelte @@ -0,0 +1,143 @@ + + +
    + + + + +
    +
    +
    + +
    + +

    Administrator access

    +

    Sign in

    +

    + {uri + ? "Scan the code with your wallet to continue." + : "Only approved reviewers can sign in."} +

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

    + + Waiting for signature… +

    + {/if} +
    + + {/if} +
    +
    +
    From dc2604569734e56ea6f3f4aab2154569b8df3154 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 10/14] feat(ppa): list platform submissions from the awareness service --- services/ppa/src/lib/server/aaas.ts | 496 ++++++++++++++++++++++++ services/ppa/src/lib/server/domains.ts | 155 ++++++++ services/ppa/src/lib/server/ontology.ts | 96 +++++ services/ppa/src/routes/+page.server.ts | 66 ++++ services/ppa/src/routes/+page.svelte | 130 +++++++ 5 files changed, 943 insertions(+) create mode 100644 services/ppa/src/lib/server/aaas.ts create mode 100644 services/ppa/src/lib/server/domains.ts create mode 100644 services/ppa/src/lib/server/ontology.ts create mode 100644 services/ppa/src/routes/+page.server.ts create mode 100644 services/ppa/src/routes/+page.svelte diff --git a/services/ppa/src/lib/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts new file mode 100644 index 000000000..f92900de6 --- /dev/null +++ b/services/ppa/src/lib/server/aaas.ts @@ -0,0 +1,496 @@ +/** + * Read side: everything the PPA knows about the outside world comes from + * Awareness-as-a-Service. + * + * Platforms publish a PlatformProfile into their own eVault under the User + * ontology, tagged with `platformName`. A platform applying for network access + * additionally sets `inSubmission: true`. AaaS fans those writes out and + * exposes them at GET /api/packets, which is what we page through here — the + * same read path the marketplace uses (platforms/marketplace/client/server/aaas.ts). + * + * The AaaS API key is a secret, so this module is server-only. + */ + +import { + type Accreditation, + type AuthorProfile, + type Messenger, + type PlatformHandle, + PLATFORM_ACCREDITATION_ONTOLOGY, + type Submission, + USER_ONTOLOGY, +} from "./ontology"; +import { + awarenessApiKey, + awarenessUrl, + messengerContactPath, + messengerPlatformName, +} from "./env"; +import { ontologyDomains } from "./domains"; + +interface Packet { + id: string; + ontology: string; + w3id: string | null; + data: Record | null; + receivedAt: string; +} + +interface PacketsResponse { + packets: Packet[]; + hasMore: boolean; + nextCursor: string | null; +} + +/** + * Whether this deployment can read the platform directory at all. Without a + * key every query returns nothing, which must not be presented as "no + * submissions" — an unconfigured app and an empty queue look identical + * otherwise, and the reviewer has no way to tell. + */ +export function isReadConfigured(): boolean { + return Boolean(awarenessApiKey()); +} + +function base(): string { + return awarenessUrl().replace(/\/$/, ""); +} + +/** One page of packets for the given filters. */ +async function page( + params: Record, + cursor?: string | null, +): Promise { + const query = new URLSearchParams(); + query.set("limit", "500"); + for (const [k, v] of Object.entries(params)) query.set(k, String(v)); + if (cursor) query.set("cursor", cursor); + + const url = `${base()}/api/packets?${query.toString()}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${awarenessApiKey()}` }, + }); + const body = await res.text(); + if (!res.ok) { + console.error( + `[ppa/aaas] ${res.status} ${res.statusText} from /api/packets: ${body.slice(0, 500)}`, + ); + throw new Error(`AaaS /api/packets returned ${res.status}`); + } + return JSON.parse(body) as PacketsResponse; +} + +/** Every packet matching the filters, paged to exhaustion (newest last). */ +async function all(params: Record): Promise { + const out: Packet[] = []; + let cursor: string | null | undefined; + do { + const res = await page(params, cursor); + out.push(...(res.packets ?? [])); + cursor = res.hasMore ? res.nextCursor : null; + } while (cursor); + return out; +} + +/** + * Listing submissions and discovering the messenger both need the whole + * User-ontology history — every user profile in the ecosystem, not just + * platforms, because AaaS can only filter by ontology. That is currently ~15MB + * over four pages and takes the better part of a minute, so the scan is cached + * and served stale while it refreshes. + * + * The TTL must stay comfortably longer than a scan takes. A TTL shorter than + * the scan expires before the scan that fills it has even finished, so every + * request starts another full pass and the cache never serves anything. + */ +const FRESH_MS = 5 * 60_000; +const STALE_MS = 30 * 60_000; + +interface PacketCache { + at: number; + packets: Packet[]; +} + +// Anchored outside the module graph for the same reason as the auth sessions: +// Vite's dev SSR can instantiate a module more than once, and a per-instance +// cache would leave some requests paying for a fresh scan every time. +const STORE = Symbol.for("ppa.aaas.userPackets"); +const store = globalThis as typeof globalThis & { + [STORE]?: { cache: PacketCache | null; inflight: Promise | null }; +}; +store[STORE] ??= { cache: null, inflight: null }; +const packetStore = store[STORE]; + +/** Starts a scan, collapsing concurrent callers onto one in-flight request. */ +function refreshUserPackets(): Promise { + if (packetStore.inflight) return packetStore.inflight; + const started = Date.now(); + packetStore.inflight = all({ ontology: USER_ONTOLOGY }) + .then((packets) => { + packetStore.cache = { at: Date.now(), packets }; + console.log( + `[ppa/aaas] scanned ${packets.length} profile packet(s) in ${((Date.now() - started) / 1000).toFixed(1)}s`, + ); + return packets; + }) + .finally(() => { + packetStore.inflight = null; + }); + return packetStore.inflight; +} + +function allUserPackets(): Promise { + const cache = packetStore.cache; + if (!cache) return refreshUserPackets(); + + const age = Date.now() - cache.at; + if (age < FRESH_MS) return Promise.resolve(cache.packets); + if (age < STALE_MS) { + // Serve what we have and bring it up to date behind the request, so a + // reviewer never waits on the scan once it has run at least once. + void refreshUserPackets().catch(() => {}); + return Promise.resolve(cache.packets); + } + return refreshUserPackets(); +} + +/** Drops the cached scan so the next read reflects a just-written change. */ +export function invalidateUserPackets(): void { + packetStore.cache = null; +} + +// Warm the cache at startup so the first person to sign in does not wear the +// cost of the initial scan. +if (isReadConfigured() && !packetStore.cache && !packetStore.inflight) { + void refreshUserPackets().catch((error) => { + console.error("[ppa/aaas] initial scan failed:", error); + }); +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * PlatformProfile carries no author field today, so accept any of the shapes a + * submitting platform might reasonably use, and fall back to the platform's own + * eName so a submission is never left with nobody to talk to. + */ +function extractAuthors( + data: Record, + platformEName: string, +): string[] { + const candidates = [ + data.authorEnames, + data.authors, + data.ownerEName, + data.submittedBy, + data.contactEName, + ]; + + for (const candidate of candidates) { + const values = Array.isArray(candidate) ? candidate : [candidate]; + const enames = values + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + if (enames.length > 0) return enames; + } + + return [platformEName]; +} + +/** A platform profile packet, or null if this packet is something else. */ +function asPlatformProfile( + packet: Packet, +): { ename: string; data: Record } | null { + const data = packet.data; + // The discovery marker: separates platform profiles from user profiles + // sharing this ontology. + if (!data || typeof data.platformName !== "string" || !data.platformName) { + return null; + } + const ename = (packet.w3id ?? str(data.ename)) || ""; + if (!ename) return null; + return { ename, data }; +} + +/** + * The ontologies a platform declares it works with. Platforms publish this in + * their self-description (as Meshenger does); an explicit `ontologies` field + * is accepted too. + */ +function extractOntologies(data: Record): string[] { + const selfDescription = data.selfDescription as + | { ontologies?: unknown } + | undefined; + const candidates = [selfDescription?.ontologies, data.ontologies]; + for (const candidate of candidates) { + if (!Array.isArray(candidate)) continue; + const ids = candidate + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + if (ids.length > 0) return [...new Set(ids)]; + } + return []; +} + +/** + * Every platform currently asking for access, deduped by eName. Packets arrive + * oldest first, so a plain Map keeps the last write — a platform that has since + * cleared `inSubmission` correctly drops out of the queue. + */ +export async function listSubmissions(): Promise { + if (!awarenessApiKey()) { + console.warn( + "[ppa/aaas] PPA_AWARENESS_API_KEY / AWARENESS_API_KEY is not set — no submissions can be read", + ); + return []; + } + + const [packets, ontologyDomain] = await Promise.all([ + allUserPackets(), + ontologyDomains(), + ]); + const byEname = new Map(); + + for (const packet of packets) { + const profile = asPlatformProfile(packet); + if (!profile) continue; + + const { ename, data } = profile; + + if (data.inSubmission !== true) { + // Latest write withdrew (or never made) the application. + byEname.delete(ename); + continue; + } + + const requestedOntologies = extractOntologies(data); + // A platform asking for an ontology is asking for its domain. + const requestedDomains = [ + ...new Set( + requestedOntologies + .map((id) => ontologyDomain.get(id)) + .filter((d): d is string => Boolean(d)), + ), + ]; + + byEname.set(ename, { + ename, + platformName: str(data.platformName), + displayName: str(data.displayName) || str(data.platformName), + description: str(data.description), + category: str(data.category) || "Other", + version: str(data.version), + url: str(data.url), + logoUrl: str(data.logoUrl) || null, + authorEnames: extractAuthors(data, ename), + requestedOntologies: requestedOntologies, + requestedDomains: requestedDomains, + submissionEnvelopeId: packet.id, + submittedAt: str(data.updatedAt) || str(data.createdAt) || packet.receivedAt, + raw: data, + }); + } + + return Array.from(byEname.values()).sort((a, b) => + a.submittedAt < b.submittedAt ? 1 : -1, + ); +} + +/** + * The messenger platform, discovered on the network like any other platform + * rather than hardcoded. Returns null when it hasn't published a profile, in + * which case the UI degrades to a copy-eName button. + */ +export async function findMessenger(): Promise { + if (!awarenessApiKey()) return null; + + const wanted = messengerPlatformName().toLowerCase(); + const packets = await allUserPackets(); + + let messenger: Messenger | null = null; + for (const packet of packets) { + const profile = asPlatformProfile(packet); + if (!profile) continue; + const { data } = profile; + if (str(data.platformName).toLowerCase() !== wanted) continue; + if (data.isArchived === true || data.isActive === false) { + messenger = null; + continue; + } + // Oldest-first ordering means the last match is the current profile. + messenger = { + displayName: str(data.displayName) || str(data.platformName), + url: str(data.url) || null, + handles: parseHandles(data.handles), + }; + } + return messenger; +} + +/** Reads the `handles` a platform publishes, ignoring malformed entries. */ +function parseHandles(value: unknown): PlatformHandle[] { + if (!Array.isArray(value)) return []; + const out: PlatformHandle[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const h = raw as Record; + const openUrl = str(h.openUrl); + const ontology = str(h.ontology); + if (!openUrl || !ontology) continue; + out.push({ + label: str(h.label) || "Open", + ontology, + openUrl, + can: Array.isArray(h.can) ? h.can.filter((c) => typeof c === "string") : [], + note: str(h.note) || null, + }); + } + return out; +} + +/** + * Builds the "contact this person" link. + * + * Preference order: + * 1. A handle the messenger publishes for the User ontology — that is the + * messenger describing how to open a person, and it keeps working if it + * changes its routes. + * 2. The known contact path (`/contacts/{ename}`). Meshenger opens a + * conversation there but does not yet declare it as a handle, so it cannot + * be discovered; once it does, branch 1 takes over on its own. + * 3. The messenger's home page, so the button still goes somewhere real. + */ +export function messageLinkFor( + messenger: Messenger | null, + ename: string, +): { href: string; label: string } | null { + if (!messenger) return null; + + const handle = messenger.handles.find( + (h) => h.ontology === USER_ONTOLOGY && (h.can.length === 0 || h.can.includes("open")), + ); + if (handle) { + const href = handle.openUrl + .replaceAll("{ontology}", encodeURIComponent(handle.ontology)) + .replaceAll("{w3id}", encodeURIComponent(ename)); + return { href, label: handle.label }; + } + + if (!messenger.url) return null; + + const path = messengerContactPath().replace( + "{ename}", + encodeURIComponent(ename), + ); + try { + return { href: new URL(path, messenger.url).toString(), label: "Message" }; + } catch { + return { href: messenger.url, label: `Open ${messenger.displayName}` }; + } +} + +/** + * A person's profile, read from their own eVault's packets. Same field + * assembly as platforms/profile-editor: last write wins. + */ +export async function getProfile( + ename: string, + messenger: Messenger | null, +): Promise { + const link = messageLinkFor(messenger, ename); + const fallback: AuthorProfile = { + ename, + displayName: ename, + handle: null, + avatarUrl: null, + bio: null, + messageUrl: link?.href ?? null, + messageLabel: link?.label ?? null, + }; + + if (!awarenessApiKey()) return fallback; + + let packets: Packet[]; + try { + packets = await all({ evault: ename, ontology: USER_ONTOLOGY }); + } catch (error) { + console.error(`[ppa/aaas] failed loading profile for ${ename}:`, error); + return fallback; + } + + let profile: Record | null = null; + for (const packet of packets) { + const data = packet.data; + if (!data) continue; + // Skip the platform's own profile — we want the person, not the app. + if (typeof data.platformName === "string" && data.platformName) continue; + profile = data; + } + if (!profile) return fallback; + + return { + ename, + displayName: + str(profile.displayName) || str(profile.name) || str(profile.username) || ename, + handle: str(profile.username) || str(profile.handle) || null, + avatarUrl: str(profile.avatarUrl) || str(profile.avatar) || null, + bio: str(profile.bio) || str(profile.description) || null, + messageUrl: link?.href ?? null, + messageLabel: link?.label ?? null, + }; +} + +/** Resolves every author of a submission, tolerating individual failures. */ +export async function getAuthors( + enames: string[], + messenger: Messenger | null, +): Promise { + return Promise.all(enames.map((ename) => getProfile(ename, messenger))); +} + +/** + * Every decision the association has issued, newest first. + * + * Decisions have their own ontology, so unlike platform profiles they can be + * asked for directly — a single small query instead of paging every profile on + * the network. They live in the eVault of the platform each one is about, and + * reach here through the usual awareness fanout, so a freshly written decision + * takes a moment to appear. + */ +export async function listAccreditations(): Promise { + if (!awarenessApiKey()) return []; + + const packets = await all({ ontology: PLATFORM_ACCREDITATION_ONTOLOGY }); + const out: Accreditation[] = []; + for (const packet of packets) { + const data = packet.data; + if (!data || typeof data.platformEName !== "string") continue; + if (typeof data.jws !== "string") continue; + out.push(data as unknown as Accreditation); + } + return out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); +} + +/** + * Key for one accreditation: a decision certifies a single platform version, + * so a platform that ships a new version is unaccredited again until it is + * reviewed afresh. + */ +export function accreditationKey(ename: string, version: string): string { + return `${ename}@${version || "-"}`; +} + +/** + * The decision currently in force for each platform version. Records are + * append-only, so "current" is the newest record for that platform + version. + */ +export async function currentAccreditations(): Promise> { + const byVersion = new Map(); + for (const record of await listAccreditations()) { + const key = accreditationKey(record.platformEName, record.platformVersion); + if (!byVersion.has(key)) byVersion.set(key, record); + } + return byVersion; +} diff --git a/services/ppa/src/lib/server/domains.ts b/services/ppa/src/lib/server/domains.ts new file mode 100644 index 000000000..5fa1721cb --- /dev/null +++ b/services/ppa/src/lib/server/domains.ts @@ -0,0 +1,155 @@ +/** + * The domains a platform can be granted access to. + * + * The list is owned by the ontology service, not by this app: every schema + * declares the domain it belongs to, so granting a domain is what actually + * decides which data a platform may touch. Fetched once and cached, with the + * published list as the single source of truth. + */ + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { ontologyUrl } from "./env"; +import type { Domain } from "$lib/types"; + +const TTL_MS = 30 * 60_000; + +const STORE = Symbol.for("ppa.domains"); +const store = globalThis as typeof globalThis & { + [STORE]?: { at: number; domains: Domain[] } | null; +}; + +const SCHEMA_STORE = Symbol.for("ppa.ontologyDomains"); +const schemaStore = globalThis as typeof globalThis & { + [SCHEMA_STORE]?: { at: number; map: Map } | null; +}; + +export async function listDomains(): Promise { + const cached = store[STORE]; + if (cached && Date.now() - cached.at < TTL_MS) return cached.domains; + + const url = new URL("/domains", ontologyUrl()).toString(); + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`ontology /domains returned ${res.status}`); + const body = (await res.json()) as { domains?: Domain[] }; + const domains = (body.domains ?? []).filter( + (d) => typeof d?.id === "string" && d.id, + ); + store[STORE] = { at: Date.now(), domains }; + return domains; + } catch (error) { + console.warn( + `[ppa/domains] ${url} did not serve a domain list (${error instanceof Error ? error.message : error}); reading the published file directly`, + ); + // A reviewer with no domains to pick from cannot grant anything, so + // fall back rather than render an empty form. This is the same file + // the ontology service publishes, not a second copy of the list — it + // covers the window before a deployment picks the endpoint up. + const fromDisk = await readPublishedFile(); + if (fromDisk.length > 0) { + store[STORE] = { at: Date.now(), domains: fromDisk }; + return fromDisk; + } + return cached?.domains ?? []; + } +} + +/** + * Reads the list out of the published Domain schema in the workspace — the + * same file the ontology service serves, so this is the same list rather than + * a second copy of it. Each permitted value carries its own title and + * description, which is how a JSON Schema enum names its options. + */ +async function readPublishedFile(): Promise { + // cwd is services/ppa under both `vite dev` and `node build/index.js`. + const file = path.resolve(process.cwd(), "../ontology/schemas/domain.json"); + try { + const schema = JSON.parse(await readFile(file, "utf8")) as { + properties?: { + id?: { + oneOf?: Array<{ + const?: string; + title?: string; + description?: string; + }>; + }; + }; + }; + const options = schema.properties?.id?.oneOf ?? []; + return options + .filter((o): o is { const: string; title?: string; description?: string } => + typeof o.const === "string", + ) + .map((o) => ({ + id: o.const, + label: o.title ?? o.const, + description: o.description ?? "", + })); + } catch (error) { + console.error(`[ppa/domains] could not read ${file}:`, error); + return []; + } +} + +/** + * Which domain each ontology belongs to. A platform declares the ontologies it + * uses; the domains it is asking for are the domains those ontologies fall + * under, so this is the map that turns a self-description into a request. + */ +export async function ontologyDomains(): Promise> { + const cached = schemaStore[SCHEMA_STORE]; + if (cached && Date.now() - cached.at < TTL_MS) return cached.map; + + const url = new URL("/schemas", ontologyUrl()).toString(); + let entries: Array<[string, string]> = []; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`ontology /schemas returned ${res.status}`); + const body = (await res.json()) as Array<{ id?: string; domain?: string }>; + entries = body + .filter((x) => typeof x.id === "string" && typeof x.domain === "string") + .map((x) => [x.id as string, x.domain as string]); + } catch (error) { + console.warn( + `[ppa/domains] ${url} unavailable (${error instanceof Error ? error.message : error}); reading published schemas directly`, + ); + entries = await readSchemaDomainsFromDisk(); + } + + const map = new Map(entries); + if (map.size > 0) schemaStore[SCHEMA_STORE] = { at: Date.now(), map }; + return map.size > 0 ? map : (cached?.map ?? new Map()); +} + +/** Reads every published schema in the workspace for its domain tag. */ +async function readSchemaDomainsFromDisk(): Promise> { + const dir = path.resolve(process.cwd(), "../ontology/schemas"); + try { + const files = (await readdir(dir)).filter((f) => f.endsWith(".json")); + const out: Array<[string, string]> = []; + for (const file of files) { + try { + const schema = JSON.parse( + await readFile(path.join(dir, file), "utf8"), + ) as { schemaId?: string; domain?: string }; + if (schema.schemaId && schema.domain) { + out.push([schema.schemaId, schema.domain]); + } + } catch { + // A single unreadable schema must not blank the whole map. + } + } + return out; + } catch (error) { + console.error(`[ppa/domains] could not read ${dir}:`, error); + return []; + } +} + +/** Keeps only ids that exist in the published list, preserving its order. */ +export async function validDomains(requested: string[]): Promise { + const known = await listDomains(); + const wanted = new Set(requested); + return known.filter((d) => wanted.has(d.id)).map((d) => d.id); +} diff --git a/services/ppa/src/lib/server/ontology.ts b/services/ppa/src/lib/server/ontology.ts new file mode 100644 index 000000000..870cd7083 --- /dev/null +++ b/services/ppa/src/lib/server/ontology.ts @@ -0,0 +1,96 @@ +/** + * Ontology ids the PPA touches, and the shape it writes. + * + * Platform profiles are not their own ontology: a platform writes its + * PlatformProfile under the User ontology and tags it with `platformName`. + * That marker is what separates a platform record from an ordinary user + * profile — see docs/docs/Post Platform Guide/platform-evault-registration.md. + */ + +/** User profile — also carries PlatformProfile records, tagged by platformName. */ +export const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +/** PlatformAccreditation — the PPA's own decisions. */ +export const PLATFORM_ACCREDITATION_ONTOLOGY = + "e1749947-5a10-4973-b9fa-230d8714c36a"; + +export { ACCESS_LEVELS, isAccessLevel } from "$lib/levels"; +export type { Domain } from "$lib/types"; +export type { AccessLevel } from "$lib/levels"; + +/** A platform's submission for review, as read out of AaaS. */ +export interface Submission { + /** The platform eVault's eName — the stable key for a submission. */ + ename: string; + platformName: string; + displayName: string; + description: string; + category: string; + version: string; + url: string; + logoUrl: string | null; + authorEnames: string[]; + /** Ontology ids the platform declares it works with. */ + requestedOntologies: string[]; + /** + * Domains the platform is asking for, derived from the ontologies it + * declares. A decision can approve these or a subset — never more. + */ + requestedDomains: string[]; + submissionEnvelopeId: string; + submittedAt: string; + /** The untouched PlatformProfile payload, shown behind a disclosure. */ + raw: Record; +} + +/** A decision the PPA has issued, as read back out of its own eVault. */ +export interface Accreditation { + accreditationId: string; + platformEName: string; + platformName: string; + platformVersion: string; + decision: "granted" | "denied"; + level: string | null; + domains: string[]; + statement: string; + reviewedByEName: string; + issuerJwksUri: string; + submissionEnvelopeId: string; + status: "active" | "superseded"; + jws: string; + createdAt: string; +} + +/** + * A deep link a platform publishes about itself, as Meshenger does: an + * ontology it can open, and a URL template with {ontology} and {w3id} holes. + * Using what a platform declares is the only way to link into it that stays + * correct when it changes its routes. + */ +export interface PlatformHandle { + label: string; + ontology: string; + openUrl: string; + can: string[]; + note: string | null; +} + +/** A messenger platform discovered on the network, with what it can open. */ +export interface Messenger { + displayName: string; + url: string | null; + handles: PlatformHandle[]; +} + +/** A person behind a submission, resolved from their own eVault profile. */ +export interface AuthorProfile { + ename: string; + displayName: string; + handle: string | null; + avatarUrl: string | null; + bio: string | null; + /** Deep link into the discovered messenger, or null when it can't open one. */ + messageUrl: string | null; + /** The label the messenger gave that link, e.g. "Chat". */ + messageLabel: string | null; +} diff --git a/services/ppa/src/routes/+page.server.ts b/services/ppa/src/routes/+page.server.ts new file mode 100644 index 000000000..aa004406a --- /dev/null +++ b/services/ppa/src/routes/+page.server.ts @@ -0,0 +1,66 @@ +import type { PageServerLoad } from "./$types"; +import { + accreditationKey, + currentAccreditations, + isReadConfigured, + listSubmissions, +} from "$lib/server/aaas"; + +/** + * The review queue. Submissions come from AaaS; the decision badge comes from + * the PPA's own eVault, so a platform already ruled on is visibly distinct + * from one still waiting. + */ +export const load: PageServerLoad = async () => { + if (!isReadConfigured()) { + console.error( + "[ppa] PPA_AWARENESS_API_KEY / AWARENESS_API_KEY is not set — the submission queue cannot be read", + ); + return { submissions: [], loadError: null, connected: false }; + } + + const [submissions, decided] = await Promise.all([ + listSubmissions().catch((error) => { + console.error("[ppa] failed loading submissions:", error); + return null; + }), + currentAccreditations().catch((error) => { + console.error("[ppa] failed loading accreditations:", error); + return new Map(); + }), + ]); + + if (submissions === null) { + return { + submissions: [], + connected: true, + // Deliberately vague: the cause is operational, and the detail + // is already in the server log above. + loadError: + "We couldn't reach the platform directory just now. Try again in a moment.", + }; + } + + return { + submissions: submissions.map((submission) => { + // Scoped to the submitted version: an older version's decision + // says nothing about the one being offered now. + const decision = + decided.get( + accreditationKey(submission.ename, submission.version), + ) ?? null; + return { + ...submission, + decision: decision + ? { + decision: decision.decision, + level: decision.level, + createdAt: decision.createdAt, + } + : null, + }; + }), + loadError: null, + connected: true, + }; +}; diff --git a/services/ppa/src/routes/+page.svelte b/services/ppa/src/routes/+page.svelte new file mode 100644 index 000000000..8c7397490 --- /dev/null +++ b/services/ppa/src/routes/+page.svelte @@ -0,0 +1,130 @@ + + +
    +
    +

    Review queue

    +

    Submissions

    +

    + Platforms applying to join the network. Review each one, then + decide what level of access it should have. +

    +
    + + {#if data.submissions.length > 0} +
    +
    +

    {pending}

    +

    Awaiting review

    +
    +
    +
    +

    {data.submissions.length}

    +

    In submission

    +
    +
    + {/if} +
    + +{#if !data.connected} +
    +
    + +
    +

    Not connected to the platform directory

    +

    + Submissions can't be listed until this deployment is connected, so + this page is empty for a reason other than there being no + applications. Whoever runs this deployment needs to finish setting + it up. +

    +
    +{:else if data.loadError} +
    +

    Couldn't load submissions

    +

    {data.loadError}

    +
    +{:else if data.submissions.length === 0} +
    +
    + +
    +

    Nothing to review

    +

    + New applications appear here as platforms apply to join the network. +

    +
    +{:else} + +{/if} From 769c56343d82127ba0d39f050942cb61b77de430 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 11/14] feat(ppa): issue signed per-version decisions into the platform eVault --- services/ppa/src/lib/server/evault.ts | 125 ++++++ services/ppa/src/lib/server/jwt.ts | 115 ++++++ .../routes/.well-known/jwks.json/+server.ts | 12 + .../ppa/src/routes/decisions/+page.server.ts | 29 ++ .../ppa/src/routes/decisions/+page.svelte | 116 ++++++ .../submissions/[ename]/+page.server.ts | 148 +++++++ .../routes/submissions/[ename]/+page.svelte | 362 ++++++++++++++++++ 7 files changed, 907 insertions(+) create mode 100644 services/ppa/src/lib/server/evault.ts create mode 100644 services/ppa/src/lib/server/jwt.ts create mode 100644 services/ppa/src/routes/.well-known/jwks.json/+server.ts create mode 100644 services/ppa/src/routes/decisions/+page.server.ts create mode 100644 services/ppa/src/routes/decisions/+page.svelte create mode 100644 services/ppa/src/routes/submissions/[ename]/+page.server.ts create mode 100644 services/ppa/src/routes/submissions/[ename]/+page.svelte diff --git a/services/ppa/src/lib/server/evault.ts b/services/ppa/src/lib/server/evault.ts new file mode 100644 index 000000000..bde76ae30 --- /dev/null +++ b/services/ppa/src/lib/server/evault.ts @@ -0,0 +1,125 @@ +/** + * Write side: a decision is stored in the eVault of the platform it is about. + * + * There is no database and no association-owned eVault. The association's + * identity is its signing key — every decision carries a JWS anyone can verify + * against /.well-known/jwks.json — so the record itself belongs with the + * platform it describes, published openly for anyone to read. + * + * Reading them back goes through AaaS by ontology id (see aaas.ts), which is a + * single small query rather than a scan. + */ + +import axios from "axios"; +import { GraphQLClient, gql } from "graphql-request"; +import { registryUrl } from "./env"; +import { + type Accreditation, + PLATFORM_ACCREDITATION_ONTOLOGY, +} from "./ontology"; + +const CREATE_META_ENVELOPE = gql` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + errors { + field + message + } + } + } +`; + +interface CreateResponse { + createMetaEnvelope: { + metaEnvelope: { id: string } | null; + errors: Array<{ field: string | null; message: string }> | null; + }; +} + +let platformToken: string | null = null; +const evaultUrls = new Map(); + +export function normalizeEName(value: string): string { + return value.startsWith("@") ? value : `@${value}`; +} + +/** + * The registry mints a platform token for any name, and eVault grants a valid + * platform token access to any vault — which is what lets the association + * publish its decision into the platform's own eVault. + */ +async function getPlatformToken(): Promise { + if (platformToken) return platformToken; + const endpoint = new URL( + "/platforms/certification", + registryUrl(), + ).toString(); + const { data } = await axios.post<{ token: string }>( + endpoint, + { platform: "ppa" }, + { timeout: 10_000 }, + ); + platformToken = data.token; + return platformToken; +} + +async function resolveEVaultUrl(ename: string): Promise { + const cached = evaultUrls.get(ename); + if (cached) return cached; + const endpoint = new URL( + `/resolve?w3id=${encodeURIComponent(ename)}`, + registryUrl(), + ).toString(); + const { data } = await axios.get<{ evaultUrl?: string; uri?: string }>( + endpoint, + { timeout: 10_000 }, + ); + const resolved = data?.evaultUrl || data?.uri; + if (!resolved) throw new Error(`Registry did not resolve ${ename}`); + evaultUrls.set(ename, resolved); + return resolved; +} + +/** + * Writes one decision into the reviewed platform's eVault, with a public ACL + * so the platform, the marketplace and anyone else can read and verify it. + */ +export async function storeAccreditation( + accreditation: Accreditation, +): Promise { + const ename = normalizeEName(accreditation.platformEName); + const [baseUrl, token] = await Promise.all([ + resolveEVaultUrl(ename), + getPlatformToken(), + ]); + + const client = new GraphQLClient(new URL("/graphql", baseUrl).toString(), { + headers: { Authorization: `Bearer ${token}`, "X-ENAME": ename }, + }); + + const response = await client.request( + CREATE_META_ENVELOPE, + { + input: { + ontology: PLATFORM_ACCREDITATION_ONTOLOGY, + payload: accreditation, + acl: ["*"], + }, + }, + ); + + const errors = response.createMetaEnvelope.errors ?? []; + if (errors.length > 0) { + throw new Error( + `eVault rejected the decision: ${errors + .map((e) => `${e.field ?? "?"}: ${e.message}`) + .join("; ")}`, + ); + } + const id = response.createMetaEnvelope.metaEnvelope?.id; + if (!id) throw new Error("eVault returned no MetaEnvelope id"); + return id; +} diff --git a/services/ppa/src/lib/server/jwt.ts b/services/ppa/src/lib/server/jwt.ts new file mode 100644 index 000000000..c8b89f291 --- /dev/null +++ b/services/ppa/src/lib/server/jwt.ts @@ -0,0 +1,115 @@ +import { + type JWK, + type KeyLike, + SignJWT, + exportJWK, + generateKeyPair, + importJWK, +} from "jose"; +import { publicUrl, signingJwk } from "./env"; + +/** + * The PPA's accreditation signing identity. Every access decision is emitted + * as a compact ES256 JWS that anyone can verify against the public half served + * at /.well-known/jwks.json — so a decision stays verifiable even if it is + * copied out of the eVault it lives in. + * + * Boot-time contract (mirrors platforms/enotary/src/lib/server/jwt.ts): + * - In production, set PPA_SIGNING_JWK to the full JSON-stringified JWK + * (including `d`, the private scalar). + * - In dev, leave it unset and an ephemeral keypair is generated on first + * use so the app boots. Restarts rotate the key, which invalidates every + * previously issued statement — never acceptable outside local dev. + * + * Generate a real key with: pnpm --filter ppa generate-jwk + */ + +const KID = "ppa-accreditation-key-1"; +const ALG = "ES256"; + +let privateKey: KeyLike | undefined; +let publicJwk: Record | undefined; + +export async function generateInitialJWK(): Promise { + const { privateKey: priv } = await generateKeyPair(ALG, { + extractable: true, + }); + const jwk = await exportJWK(priv); + return { ...jwk, kid: KID, alg: ALG, use: "sig" } as JWK; +} + +async function ensureKeys(): Promise { + if (privateKey && publicJwk) return; + + const raw = signingJwk(); + let jwk: JWK; + if (raw) { + jwk = JSON.parse(raw) as JWK; + } else { + console.warn( + "[ppa/jwt] PPA_SIGNING_JWK not set; generating an ephemeral keypair (dev only). Statements signed now will not verify after a restart.", + ); + jwk = (await generateInitialJWK()) as JWK; + } + privateKey = (await importJWK(jwk, ALG)) as KeyLike; + // Strip the private scalar before stashing for /.well-known/jwks.json. + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...pub } = jwk; + publicJwk = { ...pub, kid: jwk.kid ?? KID, alg: ALG, use: "sig" }; +} + +export interface AccreditationClaims { + accreditationId: string; + platformEName: string; + platformName: string; + platformVersion: string; + decision: "granted" | "denied"; + level: string | null; + domains: string[]; + statement: string; + reviewedByEName: string; + submissionEnvelopeId: string; +} + +/** Where a verifier fetches the key set that validates our statements. */ +export function jwksUri(): string { + return new URL("/.well-known/jwks.json", publicUrl()).toString(); +} + +/** + * Sign one access decision. The version and the granted domains are inside the + * signature, so a certificate cannot be re-pointed at another release or + * widened to cover more data after the fact. + * + * No `exp`: a decision is retired by a superseding record for the same + * version, not by expiry. + */ +export async function signAccreditation( + claims: AccreditationClaims, +): Promise { + await ensureKeys(); + if (!privateKey) throw new Error("PPA signing key not initialised"); + + return new SignJWT({ + decision: claims.decision, + level: claims.level, + domains: claims.domains, + statement: claims.statement, + reviewedBy: claims.reviewedByEName, + platformName: claims.platformName, + platformVersion: claims.platformVersion, + submissionEnvelopeId: claims.submissionEnvelopeId, + }) + .setProtectedHeader({ alg: ALG, kid: KID, typ: "JWT" }) + .setIssuer(publicUrl()) + .setSubject(claims.platformEName) + .setJti(claims.accreditationId) + .setIssuedAt() + .sign(privateKey); +} + +/** Returns the JWKS shape `{ keys: [publicJwk] }` for /.well-known/jwks.json. */ +export async function getJWKS(): Promise<{ keys: Record[] }> { + await ensureKeys(); + if (!publicJwk) throw new Error("PPA signing key not initialised"); + return { keys: [publicJwk] }; +} diff --git a/services/ppa/src/routes/.well-known/jwks.json/+server.ts b/services/ppa/src/routes/.well-known/jwks.json/+server.ts new file mode 100644 index 000000000..7bb482838 --- /dev/null +++ b/services/ppa/src/routes/.well-known/jwks.json/+server.ts @@ -0,0 +1,12 @@ +import { json } from "@sveltejs/kit"; +import { getJWKS } from "$lib/server/jwt"; + +/** + * Public key set for every accreditation the PPA signs. Deliberately + * unauthenticated — this is what makes a decision verifiable by anyone. + */ +export async function GET() { + return json(await getJWKS(), { + headers: { "cache-control": "public, max-age=300" }, + }); +} diff --git a/services/ppa/src/routes/decisions/+page.server.ts b/services/ppa/src/routes/decisions/+page.server.ts new file mode 100644 index 000000000..f3077b1a9 --- /dev/null +++ b/services/ppa/src/routes/decisions/+page.server.ts @@ -0,0 +1,29 @@ +import type { PageServerLoad } from "./$types"; +import { isReadConfigured, listAccreditations } from "$lib/server/aaas"; +import { listDomains } from "$lib/server/domains"; + +export const load: PageServerLoad = async () => { + if (!isReadConfigured()) { + console.error( + "[ppa] PPA_AWARENESS_API_KEY / AWARENESS_API_KEY is not set — decisions cannot be read", + ); + return { accreditations: [], domains: [], loadError: null, connected: false }; + } + + try { + const [accreditations, domains] = await Promise.all([ + listAccreditations(), + listDomains(), + ]); + return { accreditations, domains, loadError: null, connected: true }; + } catch (error) { + console.error("[ppa] failed loading accreditations:", error); + return { + accreditations: [], + domains: [], + connected: true, + // The underlying cause is operational and already logged above. + loadError: "We couldn't load the decision record just now. Try again in a moment.", + }; + } +}; diff --git a/services/ppa/src/routes/decisions/+page.svelte b/services/ppa/src/routes/decisions/+page.svelte new file mode 100644 index 000000000..00b61e206 --- /dev/null +++ b/services/ppa/src/routes/decisions/+page.svelte @@ -0,0 +1,116 @@ + + +
    +

    Public record

    +

    Decisions

    +

    + Every decision the association has issued, newest first. Each one is + signed, so anyone can confirm where it came from. +

    +
    + +{#if !data.connected} +
    +
    + +
    +

    Record store not set up

    +

    + The association has nowhere to publish decisions yet, so none can be + listed or issued. Whoever runs this deployment needs to finish + setting it up. +

    +
    +{:else if data.loadError} +
    +

    Couldn't load decisions

    +

    {data.loadError}

    +
    +{:else if data.accreditations.length === 0} +
    +
    + +
    +

    No decisions yet

    +

    + Decisions you issue appear here. +

    +
    +{:else} +
      + {#each data.accreditations as record (record.accreditationId)} +
    • +
      + +
      +

      + {record.platformName || record.platformEName} + {#if record.platformVersion} + v{record.platformVersion} + {/if} +

      +

      {record.platformEName}

      +
      +

      {record.createdAt.slice(0, 10)}

      +
      + + {#if record.domains?.length} +
      +

      Areas of access

      +
      + +
      +
      + {/if} + +

      + {record.statement} +

      + +
      +

      + Reviewed by + {record.reviewedByEName} +

      + +
      +
    • + {/each} +
    +{/if} diff --git a/services/ppa/src/routes/submissions/[ename]/+page.server.ts b/services/ppa/src/routes/submissions/[ename]/+page.server.ts new file mode 100644 index 000000000..5cddaa78b --- /dev/null +++ b/services/ppa/src/routes/submissions/[ename]/+page.server.ts @@ -0,0 +1,148 @@ +import { randomUUID } from "node:crypto"; +import { error, fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import { + accreditationKey, + currentAccreditations, + findMessenger, + getAuthors, + listSubmissions, +} from "$lib/server/aaas"; +import { storeAccreditation } from "$lib/server/evault"; +import { jwksUri, signAccreditation } from "$lib/server/jwt"; +import { type Accreditation, isAccessLevel } from "$lib/server/ontology"; +import { listDomains, validDomains } from "$lib/server/domains"; + +export const load: PageServerLoad = async ({ params }) => { + const ename = decodeURIComponent(params.ename); + + const [submissions, messenger, decided, domains] = await Promise.all([ + listSubmissions(), + findMessenger(), + currentAccreditations().catch(() => new Map()), + listDomains(), + ]); + + const submission = submissions.find((s) => s.ename === ename); + if (!submission) { + throw error(404, "This platform isn't awaiting review."); + } + + return { + submission, + authors: await getAuthors(submission.authorEnames, messenger), + messengerConfigured: messenger !== null, + domains, + // Only what this platform asked for is offered to the reviewer. + requestedDomains: domains.filter((d) => + submission.requestedDomains.includes(d.id), + ), + currentDecision: + decided.get(accreditationKey(ename, submission.version)) ?? null, + }; +}; + +export const actions: Actions = { + /** + * Issue a decision: sign it, then write it into the PPA's eVault. The JWS + * is produced first so that nothing is ever persisted unsigned. + */ + decide: async ({ request, params, locals }) => { + const reviewer = locals.user?.ename; + if (!reviewer) return fail(401, { message: "Your session has ended. Sign in again." }); + + const form = await request.formData(); + const decision = String(form.get("decision") ?? ""); + const rawLevel = String(form.get("level") ?? ""); + const statement = String(form.get("statement") ?? "").trim(); + const requested = await validDomains( + form.getAll("domains").map((d) => String(d)), + ); + + if (decision !== "granted" && decision !== "denied") { + return fail(400, { message: "Choose whether to grant or deny access." }); + } + if (!statement) { + return fail(400, { + message: "Add a short explanation — it is published with your decision.", + decision, + level: rawLevel, + }); + } + if (decision === "granted" && !isAccessLevel(rawLevel)) { + return fail(400, { + message: "Choose an access level.", + decision, + statement, + }); + } + + + const ename = decodeURIComponent(params.ename); + const submission = (await listSubmissions()).find((s) => s.ename === ename); + if (!submission) { + return fail(404, { message: "This platform is no longer awaiting review." }); + } + + // A decision approves what the platform asked for, or less. It can + // never hand out access the platform never requested, so the grant is + // intersected with the request rather than trusted from the form. + const askedFor = new Set(submission.requestedDomains); + const domains = requested.filter((d) => askedFor.has(d)); + + if (decision === "granted" && domains.length === 0) { + return fail(400, { + message: + submission.requestedDomains.length === 0 + ? "This platform has not requested any areas of access, so there is nothing to approve." + : "Approve at least one of the areas this platform requested.", + decision, + statement, + level: rawLevel, + }); + } + + const level = decision === "granted" ? (rawLevel as string) : null; + const accreditationId = randomUUID(); + + try { + const jws = await signAccreditation({ + accreditationId, + platformEName: ename, + platformName: submission.platformName, + platformVersion: submission.version, + domains: decision === "granted" ? domains : [], + decision, + level, + statement, + reviewedByEName: reviewer, + submissionEnvelopeId: submission.submissionEnvelopeId, + }); + + const accreditation: Accreditation = { + accreditationId, + platformEName: ename, + platformName: submission.platformName, + platformVersion: submission.version, + decision, + level, + domains: decision === "granted" ? domains : [], + statement, + reviewedByEName: reviewer, + issuerJwksUri: jwksUri(), + submissionEnvelopeId: submission.submissionEnvelopeId, + status: "active", + jws, + createdAt: new Date().toISOString(), + }; + + await storeAccreditation(accreditation); + return { issued: accreditation }; + } catch (err) { + console.error("[ppa] failed issuing accreditation:", err); + return fail(500, { + message: "Something went wrong issuing the decision. Try again.", + }); + } + }, +}; diff --git a/services/ppa/src/routes/submissions/[ename]/+page.svelte b/services/ppa/src/routes/submissions/[ename]/+page.svelte new file mode 100644 index 000000000..3dd86bf9e --- /dev/null +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -0,0 +1,362 @@ + + + + + Submissions + + +
    + +
    +
    +

    {data.submission.displayName}

    + {#if data.submission.version} + v{data.submission.version} + {/if} + +
    +

    {data.submission.ename}

    +
    +
    + +
    + +
    +
    +

    Application

    + +
    + {#each facts as fact (fact.label)} +
    +
    {fact.label}
    +
    {fact.value}
    +
    + {/each} +
    +
    URL
    +
    + {#if data.submission.url} + + {data.submission.url} + + {:else} + + {/if} +
    +
    +
    + + {#if data.submission.description} +
    +
    Description
    +

    + {data.submission.description} +

    +
    + {/if} + +
    + + All submitted details + +
    {JSON.stringify(
    +                        data.submission.raw,
    +                        null,
    +                        2,
    +                    )}
    +
    +
    + +
    +
    +

    Authors

    + {#if !data.messengerConfigured} +

    Messaging unavailable

    + {/if} +
    + +
      + {#each data.authors as author (author.ename)} +
    • + {#if author.avatarUrl} + + {:else} +
      + {author.displayName.slice(0, 1).toUpperCase()} +
      + {/if} + +
      +

      {author.displayName}

      +

      {author.ename}

      + {#if author.bio} +

      {author.bio}

      + {/if} +
      + + {#if author.messageUrl} + + {author.messageLabel ?? "Message"} + + {:else} + + {/if} +
    • + {/each} +
    +
    +
    + + + +
    From 30121d761c286a68030da43f8922b3bcc2893180 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:38 +0800 Subject: [PATCH 12/14] docs(ppa): add setup guide, key generator and dev fixture --- services/ppa/README.md | 95 +++++++++++++ services/ppa/config/admin-enames.json | 3 + services/ppa/scripts/generate-ppa-jwk.cjs | 29 ++++ services/ppa/scripts/seed-submission.ts | 164 ++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 services/ppa/README.md create mode 100644 services/ppa/config/admin-enames.json create mode 100644 services/ppa/scripts/generate-ppa-jwk.cjs create mode 100644 services/ppa/scripts/seed-submission.ts diff --git a/services/ppa/README.md b/services/ppa/README.md new file mode 100644 index 000000000..e0cfb7290 --- /dev/null +++ b/services/ppa/README.md @@ -0,0 +1,95 @@ +# Post Platforms Association (PPA) + +Admin app where a whitelisted group of reviewers vets platforms applying to +join the network and issues **L1–L5 access decisions as signed statements**. + +- **Submissions** are read from Awareness-as-a-Service: platform profiles + (User ontology, tagged `platformName`) that also carry `inSubmission: true`. +- **Authors** are resolved from their own eVault profiles, with a "Message" + button that deep-links into whichever messenger platform is currently + published on AaaS — nothing is hardcoded. +- **Decisions certify one platform version.** A grant carries an access level + (L1–L5) and the **areas of access** it covers — domains published by the + ontology service, which every schema is tagged with. Shipping a new version + means a new review; the old certificate does not carry over. +- **Decisions** are ES256 JWS statements written into the eVault of the platform + they are about, with a public ACL, so the record travels with that platform. + The association owns no vault and no database: its identity is a signing key, + and every decision verifies against `/.well-known/jwks.json` without trusting + the app or the eVault holding it. They are read back by their own ontology id + — one small query, not a scan. + +Runs on **port 4210** (`--strictPort`, so it never takes a port from anything +else). SvelteKit + Tailwind 4 + `adapter-node`. + +## Setup + +All configuration lives in the repo-root `.env`; see the `PPA_*` block in +`.env.example`. + +**1. Signing key** — the identity behind every statement: + +```sh +pnpm --filter ppa generate-jwk # -> PPA_SIGNING_JWK +``` + +Left unset, an ephemeral key is generated per process: fine for a first look, +but every statement stops verifying on restart. + +**2. The admin whitelist** — `config/admin-enames.json`: + +```json +{ "admins": ["@your-ename"] } +``` + +Re-read on change, so an eName can be added or removed without a restart, and +removal revokes any live session on the next request. `PPA_ADMIN_ENAMES` (csv) +is merged in for deployments where mounting a file is awkward. + +**3. An AaaS consumer key** — approve a consumer in the AaaS portal, issue a +key, and set `PPA_AWARENESS_API_KEY` (it falls back to `AWARENESS_API_KEY`). + +**4. Run:** + +```sh +pnpm --filter ppa dev # http://localhost:4210 +``` + +## Trying it locally + +Nothing in the repo writes `inSubmission` yet, so there is a dev-only fixture +that provisions throwaway eVaults holding an author profile and a platform +asking for access: + +```sh +pnpm --filter ppa seed:submission +``` + +## Verifying a statement + +A decision is self-contained — a verifier needs the JWS and the issuer's key +set, nothing else: + +```sh +curl -s localhost:4210/.well-known/jwks.json +``` + +```js +import { jwtVerify, createLocalJWKSet } from "jose"; + +const jwks = await (await fetch(`${issuer}/.well-known/jwks.json`)).json(); +const { payload } = await jwtVerify(jws, createLocalJWKSet(jwks)); +// { decision, level, statement, reviewedBy, sub: , iss, jti, iat } +``` + +`issuerJwksUri` on the stored record points at the right key set. Editing any +claim — the level above all — invalidates the signature. + +## Notes + +- Decisions are **append-only**. A re-decision writes a new record into the + platform's eVault and the newest one is in force; nothing is rewritten. +- The PPA is deliberately **not** in the Registry's platform list. That list + drives AaaS catch-all webhook fanout, and the PPA polls rather than receives, + so registering it would only produce dead-letters. It still mints a platform + token from `POST /platforms/certification`, which is issued for any name. diff --git a/services/ppa/config/admin-enames.json b/services/ppa/config/admin-enames.json new file mode 100644 index 000000000..6938f2300 --- /dev/null +++ b/services/ppa/config/admin-enames.json @@ -0,0 +1,3 @@ +{ + "admins": ["@849c0221-6f3f-55f9-95f0-f3b0d2b3092f"] +} diff --git a/services/ppa/scripts/generate-ppa-jwk.cjs b/services/ppa/scripts/generate-ppa-jwk.cjs new file mode 100644 index 000000000..822fba485 --- /dev/null +++ b/services/ppa/scripts/generate-ppa-jwk.cjs @@ -0,0 +1,29 @@ +/** + * Generate an ES256 JWK for PPA_SIGNING_JWK. Outputs JWK JSON to stdout. + * Run from the repo root: pnpm --filter ppa generate-jwk + */ +const { generateKeyPair, exportJWK } = require("jose"); + +async function main() { + const { privateKey } = await generateKeyPair("ES256", { + extractable: true, + }); + const jwk = await exportJWK(privateKey); + jwk.kid = "ppa-accreditation-key-1"; + jwk.alg = "ES256"; + jwk.use = "sig"; + process.stdout.write("PPA ACCREDITATION SIGNING KEY GENERATED\n"); + process.stdout.write("-------------------------------------------------\n"); + process.stdout.write("this key is a secret and should not be shared\n"); + process.stdout.write( + "rotating it invalidates every statement already issued\n", + ); + process.stdout.write("Add this to your .env as PPA_SIGNING_JWK:\n\n"); + + process.stdout.write(JSON.stringify(jwk) + "\n"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/services/ppa/scripts/seed-submission.ts b/services/ppa/scripts/seed-submission.ts new file mode 100644 index 000000000..b9c8ceaa3 --- /dev/null +++ b/services/ppa/scripts/seed-submission.ts @@ -0,0 +1,164 @@ +/** + * Dev-only fixture. Nothing in the repo writes `inSubmission` yet, so this + * fabricates a reviewable submission end to end: an author eVault holding a + * user profile, and a platform eVault holding a PlatformProfile that points at + * that author and asks for access. + * + * pnpm --filter ppa seed:submission + * + * Never run this against a real deployment — it provisions throwaway eVaults. + */ + +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import axios from "axios"; +import { config } from "dotenv"; +import { GraphQLClient, gql } from "graphql-request"; + +// The package is ESM, so __dirname does not exist here. +const here = path.dirname(fileURLToPath(import.meta.url)); +config({ path: path.resolve(here, "../../../.env") }); + +const registryUrl = process.env.PUBLIC_REGISTRY_URL || "http://localhost:4321"; +const provisionerUrl = + process.env.PUBLIC_PROVISIONER_URL || "http://localhost:3001"; +const verificationId = process.env.DEMO_VERIFICATION_CODE || ""; + +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +const CREATE_META_ENVELOPE = gql` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + errors { + field + message + } + } + } +`; + +async function provision(): Promise<{ w3id: string; uri: string }> { + const { + data: { token: registryEntropy }, + } = await axios.get<{ token: string }>( + new URL("/entropy", registryUrl).toString(), + { timeout: 10_000 }, + ); + const { data } = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: randomUUID(), + verificationId, + publicKey: "0x0000000000000000000000000000000000000000", + }, + { timeout: 30_000 }, + ); + if (!data?.w3id) { + throw new Error( + `Provisioner did not return a w3id: ${JSON.stringify(data)}`, + ); + } + return { w3id: data.w3id, uri: data.uri }; +} + +async function platformToken(): Promise { + const { data } = await axios.post<{ token: string }>( + new URL("/platforms/certification", registryUrl).toString(), + { platform: "ppa-seed" }, + { timeout: 10_000 }, + ); + return data.token; +} + +async function write( + evaultUri: string, + ename: string, + token: string, + payload: Record, +): Promise { + const client = new GraphQLClient( + new URL("/graphql", evaultUri).toString(), + { + headers: { Authorization: `Bearer ${token}`, "X-ENAME": ename }, + }, + ); + const res = await client.request<{ + createMetaEnvelope: { + metaEnvelope: { id: string } | null; + errors: Array<{ field: string | null; message: string }> | null; + }; + }>(CREATE_META_ENVELOPE, { + input: { ontology: USER_ONTOLOGY, payload, acl: ["*"] }, + }); + const errors = res.createMetaEnvelope.errors ?? []; + if (errors.length > 0) { + throw new Error(errors.map((e) => e.message).join("; ")); + } + const id = res.createMetaEnvelope.metaEnvelope?.id; + if (!id) throw new Error("eVault returned no MetaEnvelope id"); + return id; +} + +async function main(): Promise { + if (!verificationId) { + throw new Error( + "DEMO_VERIFICATION_CODE is required to provision eVaults locally", + ); + } + + const now = new Date().toISOString(); + const token = await platformToken(); + + console.log("[seed] provisioning an author eVault"); + const author = await provision(); + await write(author.uri, author.w3id, token, { + displayName: "Robin Fairweather", + username: "robin", + bio: "Building a tide-tracking platform for coastal communities.", + avatarUrl: "", + ename: author.w3id, + createdAt: now, + updatedAt: now, + }); + console.log(`[seed] author: ${author.w3id}`); + + console.log("[seed] provisioning a platform eVault"); + const platform = await provision(); + const slug = `tidewatch-${platform.w3id.slice(1, 7)}`; + await write(platform.uri, platform.w3id, token, { + platformName: slug, + displayName: "Tidewatch", + description: + "Community tide and flood reporting. Applying for network access so members can carry their reports between platforms.", + version: "0.3.1", + ename: platform.w3id, + isActive: true, + isArchived: false, + inSubmission: true, + authorEnames: [author.w3id], + createdAt: now, + updatedAt: now, + url: "https://tidewatch.example", + logoUrl: "", + category: "Wellness", + }); + + console.log("\nSEEDED SUBMISSION"); + console.log("-------------------------------------------------"); + console.log(`platform eName: ${platform.w3id}`); + console.log(`platform slug: ${slug}`); + console.log(`author eName: ${author.w3id}`); + console.log( + "\nAaaS ingests on a short delay — give it a moment, then reload the PPA submissions list.", + ); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); From 35fc59dd69001dd07c1a7df96cb40b9bc6fb787f Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:45 +0800 Subject: [PATCH 13/14] fix(marketplace): hide platforms still in submission or draft --- platforms/marketplace/client/server/aaas.ts | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/platforms/marketplace/client/server/aaas.ts b/platforms/marketplace/client/server/aaas.ts index aefb11096..f74fb85d1 100644 --- a/platforms/marketplace/client/server/aaas.ts +++ b/platforms/marketplace/client/server/aaas.ts @@ -8,6 +8,9 @@ * `platformName` field. AaaS fans those writes out and exposes them at * `GET /api/packets?ontology=...`, which is what we page through here. * + * Platforms still in submission, drafts, archived and inactive profiles are + * filtered out here — the catalogue only shows what is live. + * * The AaaS API key is a secret, so this only ever runs server-side. * * Env: @@ -119,21 +122,35 @@ export async function listPlatforms(): Promise { let profileCount = 0; let skippedNoPlatformName = 0; let skippedArchived = 0; + let skippedUnpublished = 0; for (const p of packets) { const data = p.data; // Isolate platform profiles from ordinary user profiles sharing the - // ontology; skip archived/inactive platforms. + // ontology. if (!data || typeof data.platformName !== "string" || !data.platformName) { skippedNoPlatformName++; continue; } + + const ename = (p.w3id ?? (data.ename as string | undefined)) || ""; + if (!ename) continue; + + // Excluded platforms are deleted rather than skipped: a profile that was + // listed earlier and later withdrawn, archived or put back into review + // must drop out of the catalogue, and the older packet has already been + // written into the map. if (data.isArchived === true || data.isActive === false) { skippedArchived++; + byEname.delete(ename); + continue; + } + // Not ready to be discovered: still applying to join the network, or not + // yet published by its authors. + if (data.inSubmission === true || data.isDraft === true) { + skippedUnpublished++; + byEname.delete(ename); continue; } - - const ename = (p.w3id ?? (data.ename as string | undefined)) || ""; - if (!ename) continue; profileCount++; byEname.set(ename, { @@ -150,7 +167,8 @@ export async function listPlatforms(): Promise { const platforms = Array.from(byEname.values()); console.log( `[awareness] platform profiles=${profileCount} (deduped=${platforms.length}), ` + - `skipped: non-platform=${skippedNoPlatformName}, archived/inactive=${skippedArchived}`, + `skipped: non-platform=${skippedNoPlatformName}, archived/inactive=${skippedArchived}, ` + + `in-submission/draft=${skippedUnpublished}`, ); console.log( `[awareness] platforms: ${platforms.map((p) => p.id).join(", ") || "(none)"}`, From d5e96dc40ba7fe6dbbd6799aa3e77676a1932787 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 00:05:45 +0800 Subject: [PATCH 14/14] chore(env): document association and awareness settings --- .env.example | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.env.example b/.env.example index b78105e49..abd8b0b32 100644 --- a/.env.example +++ b/.env.example @@ -162,3 +162,36 @@ AWARENESS_DELIVERY_POLL_MS=2000 # directly, so there are no AaaS-specific Neo4j vars. # Portal -> API base URL PUBLIC_AWARENESS_API_URL="http://localhost:4100" +# Consumer API key (aaas_...) issued from the AaaS portal, used by platforms +# that read the packet history (marketplace, profile-editor, ppa) +AWARENESS_API_KEY="" + +# --------------------------------------------------------------------------- +# Post Platforms Association (PPA) - services/ppa +# Admin app where whitelisted reviewers issue signed L1-L5 access decisions on +# platforms that publish a profile with `inSubmission: true`. +# --------------------------------------------------------------------------- +# Public base URL: the w3ds://auth callback target and the `iss` of every +# signed statement. Must match where the app is actually reachable. +PPA_PUBLIC_URL="http://localhost:4210" +# Signs the admin session cookie +PPA_JWT_SECRET="replace-with-a-strong-secret" +# ES256 private JWK signing every accreditation; generate with: +# pnpm --filter ppa generate-jwk +# Left unset in dev an ephemeral key is used and statements stop verifying +# after a restart. +PPA_SIGNING_JWK="" +# Admin whitelist. The JSON file is authoritative and hot-reloads; the csv is +# merged in for deployments where mounting a file is awkward. +PPA_ADMIN_ENAMES_FILE="config/admin-enames.json" +PPA_ADMIN_ENAMES="" +# AaaS consumer key used to read platform submissions; falls back to +# AWARENESS_API_KEY when unset. +PPA_AWARENESS_API_KEY="" +# The messenger platform is discovered on the network by its platformName. +# How to link into it comes from the handles that platform publishes, so there +# is no path to configure here. +PPA_MESSENGER_PLATFORM_NAME="meshenger" +# Path that opens a conversation with one person. Only used when the messenger +# publishes no handle for the User ontology; a declared handle always wins. +PPA_MESSENGER_CONTACT_PATH="/contacts/{ename}"