decentralised sync engine
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: serve did doc

serenity 9a85d185 4362aac3

+356 -6
+11
.example.env
··· 1 1 # port for the lattice server to run on. 2 2 # defaults to 7338. 3 3 SERVER_PORT="7338" 4 + 5 + # used for verifying inter-service jwts 6 + # you *must* specify a did at which this shard may be found. may also include a service identifier. 7 + # for more information on the service identifier, you may see https://atproto.com/specs/xrpc#inter-service-authentication-jwt 8 + # usually a did:web, but if you're crazy you can put a did:plc, the verifier supports either anyway. 9 + # defaults to did:web:localhost 10 + SERVICE_DID="did:web:localhost" 11 + 12 + # to tell if you're in dev or prod. defaults to dev. 13 + # if running in prod, set to 'production' 14 + NODE_ENV="development"
+6 -3
package.json
··· 2 2 "name": "@gmstn/lattice", 3 3 "version": "0.0.1", 4 4 "description": "decentralised sync engine", 5 - "main": "index.js", 5 + "exports": "./index.js", 6 6 "scripts": { 7 7 "test": "echo \"Error: no test specified\" && exit 1", 8 8 "dev": "tsx src/index.ts", ··· 20 20 "globals": "^16.4.0", 21 21 "jiti": "^2.6.1", 22 22 "prettier": "^3.6.2", 23 - "tsx": "^4.20.6", 24 23 "typescript": "^5.9.3", 25 24 "typescript-eslint": "^8.46.0" 26 25 }, 27 26 "dependencies": { 27 + "@atcute/crypto": "^2.2.5", 28 28 "@fastify/websocket": "^11.2.0", 29 29 "dotenv": "^17.2.3", 30 30 "fastify": "^5.6.1", 31 + "tsx": "^4.20.6", 32 + "uint8arrays": "^5.1.0", 31 33 "ws": "^8.18.3", 32 34 "zod": "^4.1.12" 33 - } 35 + }, 36 + "type": "module" 34 37 }
+47 -3
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atcute/crypto': 12 + specifier: ^2.2.5 13 + version: 2.2.5 11 14 '@fastify/websocket': 12 15 specifier: ^11.2.0 13 16 version: 11.2.0 ··· 17 20 fastify: 18 21 specifier: ^5.6.1 19 22 version: 5.6.1 23 + tsx: 24 + specifier: ^4.20.6 25 + version: 4.20.6 26 + uint8arrays: 27 + specifier: ^5.1.0 28 + version: 5.1.0 20 29 ws: 21 30 specifier: ^8.18.3 22 31 version: 8.18.3 ··· 45 54 prettier: 46 55 specifier: ^3.6.2 47 56 version: 3.6.2 48 - tsx: 49 - specifier: ^4.20.6 50 - version: 4.20.6 51 57 typescript: 52 58 specifier: ^5.9.3 53 59 version: 5.9.3 ··· 56 62 version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) 57 63 58 64 packages: 65 + 66 + '@atcute/crypto@2.2.5': 67 + resolution: {integrity: sha512-9CbQ9cJ68XewsbLrgdmWQS2uDD9D0hizWFJ3OOZ16TCuARREmzKEpFgHlMxPswR3bDxjwfiXzmYUlHaTqsnxRQ==} 68 + 69 + '@atcute/multibase@1.1.6': 70 + resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 71 + 72 + '@atcute/uint8array@1.0.5': 73 + resolution: {integrity: sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q==} 59 74 60 75 '@esbuild/aix-ppc64@0.25.11': 61 76 resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} ··· 287 302 '@humanwhocodes/retry@0.4.3': 288 303 resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} 289 304 engines: {node: '>=18.18'} 305 + 306 + '@noble/secp256k1@2.3.0': 307 + resolution: {integrity: sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==} 290 308 291 309 '@nodelib/fs.scandir@2.1.5': 292 310 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} ··· 715 733 ms@2.1.3: 716 734 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 717 735 736 + multiformats@13.4.1: 737 + resolution: {integrity: sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q==} 738 + 718 739 natural-compare@1.4.0: 719 740 resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 720 741 ··· 909 930 engines: {node: '>=14.17'} 910 931 hasBin: true 911 932 933 + uint8arrays@5.1.0: 934 + resolution: {integrity: sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==} 935 + 912 936 undici-types@7.14.0: 913 937 resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} 914 938 ··· 951 975 952 976 snapshots: 953 977 978 + '@atcute/crypto@2.2.5': 979 + dependencies: 980 + '@atcute/multibase': 1.1.6 981 + '@atcute/uint8array': 1.0.5 982 + '@noble/secp256k1': 2.3.0 983 + 984 + '@atcute/multibase@1.1.6': 985 + dependencies: 986 + '@atcute/uint8array': 1.0.5 987 + 988 + '@atcute/uint8array@1.0.5': {} 989 + 954 990 '@esbuild/aix-ppc64@0.25.11': 955 991 optional: true 956 992 ··· 1117 1153 '@humanwhocodes/module-importer@1.0.1': {} 1118 1154 1119 1155 '@humanwhocodes/retry@0.4.3': {} 1156 + 1157 + '@noble/secp256k1@2.3.0': {} 1120 1158 1121 1159 '@nodelib/fs.scandir@2.1.5': 1122 1160 dependencies: ··· 1620 1658 1621 1659 ms@2.1.3: {} 1622 1660 1661 + multiformats@13.4.1: {} 1662 + 1623 1663 natural-compare@1.4.0: {} 1624 1664 1625 1665 on-exit-leak-free@2.1.2: {} ··· 1788 1828 - supports-color 1789 1829 1790 1830 typescript@5.9.3: {} 1831 + 1832 + uint8arrays@5.1.0: 1833 + dependencies: 1834 + multiformats: 13.4.1 1791 1835 1792 1836 undici-types@7.14.0: {} 1793 1837
+15
src/lib/env.ts
··· 1 + import { didSchema } from "@/lib/types/atproto"; 1 2 import "dotenv/config"; 2 3 3 4 const nodeEnv = process.env.NODE_ENV; ··· 12 13 "Environment variable SERVER_PORT not set. Defaulting to 7338", 13 14 ); 14 15 export const SERVER_PORT = Number.parseInt(serverPort ?? "7338"); 16 + 17 + const serviceDid = process.env.SERVICE_DID; 18 + const { 19 + success: serviceDidParseSuccess, 20 + error: serviceDidParseError, 21 + data: serviceDidParsed, 22 + } = didSchema.safeParse(serviceDid); 23 + if (!serviceDidParseSuccess) { 24 + console.warn(serviceDidParseError); 25 + console.warn( 26 + "Environment variable SERVICE_DID not set. Defaulting to `did:web:localhost`", 27 + ); 28 + } 29 + export const SERVICE_DID = serviceDidParsed ?? "did:web:localhost";
+19
src/lib/types/http/errors.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const HttpGeneralErrorType = { 4 + TYPE_ERROR: "Type error", 5 + PARAMS_ERROR: "Missing required params", 6 + SERVER_ERROR: "Something went wrong on the server", 7 + }; 8 + export const httpGeneralErrorTypeSchema = z.enum(HttpGeneralErrorType); 9 + export type HttpGeneralErrorType = z.infer<typeof httpGeneralErrorTypeSchema>; 10 + 11 + export const httpErrorTypeSchema = z.union([httpGeneralErrorTypeSchema]); 12 + export type HttpErrorType = z.infer<typeof httpErrorTypeSchema>; 13 + 14 + export const httpResponseErrorInfoSchema = z.object({ 15 + message: z.string(), 16 + type: z.optional(httpErrorTypeSchema), 17 + details: z.optional(z.unknown()), 18 + }); 19 + export type HttpResponseErrorInfo = z.infer<typeof httpResponseErrorInfoSchema>;
+43
src/lib/types/http/responses.ts
··· 1 + import z from "zod"; 2 + import { httpResponseErrorInfoSchema } from "@/lib/types/http/errors"; 3 + 4 + export const HttpResponseStatusType = { 5 + SUCCESS: "success", 6 + ERROR: "error", 7 + } as const; 8 + export const httpResponseStatusTypeSchema = z.enum(HttpResponseStatusType); 9 + export type HttpResponseStatusType = z.infer< 10 + typeof httpResponseStatusTypeSchema 11 + >; 12 + 13 + export const handshakeResponseSchema = z.object({ 14 + sessionInfo: z.unknown(), 15 + }); 16 + export type HandshakeResponse = z.infer<typeof handshakeResponseSchema>; 17 + 18 + export const httpResponseDataSchema = z.union([handshakeResponseSchema]); 19 + export type HttpResponseData = z.infer<typeof httpResponseDataSchema>; 20 + 21 + const httpResponseBaseSchema = z.object({ 22 + status: httpResponseStatusTypeSchema, 23 + data: z.optional(httpResponseDataSchema), 24 + error: z.optional(httpResponseErrorInfoSchema), 25 + }); 26 + 27 + export const httpSuccessResponseSchema = httpResponseBaseSchema 28 + .safeExtend({ 29 + status: z.literal(HttpResponseStatusType.SUCCESS), 30 + data: httpResponseDataSchema, 31 + error: z.undefined(), 32 + }) 33 + .omit({ error: true }); 34 + export type HttpSuccessResponse = z.infer<typeof httpSuccessResponseSchema>; 35 + 36 + export const httpErrorResponseSchema = httpResponseBaseSchema 37 + .safeExtend({ 38 + status: z.literal(HttpResponseStatusType.ERROR), 39 + error: httpResponseErrorInfoSchema, 40 + data: z.undefined(), 41 + }) 42 + .omit({ data: true }); 43 + export type HttpErrorResponse = z.infer<typeof httpErrorResponseSchema>;
+118
src/lib/utils/didDoc.ts
··· 1 + import { __DEV__, SERVER_PORT, SERVICE_DID } from "@/lib/env"; 2 + import { 3 + didWebSchema, 4 + type DidDocument, 5 + type DidWeb, 6 + type VerificationMethod, 7 + } from "@/lib/types/atproto"; 8 + import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 9 + import { toString as uint8arraysToString } from "uint8arrays"; 10 + 11 + export interface ServiceKeys { 12 + atproto: Secp256k1PrivateKeyExportable; 13 + service: Secp256k1PrivateKeyExportable; 14 + } 15 + 16 + export interface CreateDidWebDocResult { 17 + didDoc: DidDocument; 18 + keys: ServiceKeys; 19 + } 20 + 21 + const buildDidWebDoc = async ( 22 + didWeb: DidWeb, 23 + ): Promise<CreateDidWebDocResult> => { 24 + const atprotoKey = await Secp256k1PrivateKeyExportable.createKeypair(); 25 + const serviceKey = await Secp256k1PrivateKeyExportable.createKeypair(); 26 + 27 + const atprotoMultikey = encodeMultikey( 28 + await atprotoKey.exportPublicKey("raw"), 29 + ); 30 + const serviceMultikey = encodeMultikey( 31 + await atprotoKey.exportPublicKey("raw"), 32 + ); 33 + 34 + const { domain, serviceEndpoint } = extractInfoFromDidWeb(didWeb); 35 + 36 + const verificationMethod: Array<VerificationMethod> = [ 37 + { 38 + id: `${didWeb}#atproto`, 39 + type: "Multikey", 40 + controller: didWeb, 41 + publicKeyMultibase: atprotoMultikey, 42 + }, 43 + ]; 44 + 45 + const didDoc: DidDocument = { 46 + "@context": [ 47 + "https://www.w3.org/ns/did/v1", 48 + "https://w3id.org/security/multikey/v1", 49 + "https://w3id.org/security/suites/secp256k1-2019/v1", 50 + ], 51 + id: didWeb, 52 + verificationMethod, 53 + }; 54 + 55 + if (serviceEndpoint) { 56 + const serviceEndpointType = "GemstoneShard"; 57 + 58 + const serviceEndpointUrl = `https://${domain}/`; 59 + 60 + // @ts-expect-error we are already adding the verificationMethod array above when we create didDoc. 61 + didDoc.verificationMethod.push({ 62 + id: `${didWeb}#${serviceEndpoint}`, 63 + type: "Multikey", 64 + controller: didWeb, 65 + publicKeyMultibase: serviceMultikey, 66 + }); 67 + 68 + didDoc.service = [ 69 + { 70 + id: `${didWeb}#${serviceEndpoint}`, 71 + type: serviceEndpointType, 72 + serviceEndpoint: serviceEndpointUrl, 73 + }, 74 + ]; 75 + } 76 + 77 + return { 78 + didDoc, 79 + keys: { 80 + atproto: atprotoKey, 81 + service: serviceKey, 82 + }, 83 + }; 84 + }; 85 + 86 + const encodeMultikey = (publicKeyBytes: Uint8Array) => { 87 + // For secp256k1 (K-256), prefix with 0xE701 88 + const prefixed = new Uint8Array(publicKeyBytes.length + 2); 89 + prefixed[0] = 0xe7; 90 + prefixed[1] = 0x01; 91 + prefixed.set(publicKeyBytes, 2); 92 + 93 + // Base58-btc encode with 'z' prefix 94 + const value = uint8arraysToString(prefixed, "base58btc"); 95 + 96 + return "z" + value; 97 + }; 98 + 99 + const extractInfoFromDidWeb = (didWeb: DidWeb) => { 100 + const fragments = didWeb.split("#"); 101 + return { 102 + domain: fragments[0].replace("did:web:", ""), 103 + serviceEndpoint: fragments[1] as string | undefined, 104 + }; 105 + }; 106 + 107 + const createDidWebDoc = async () => { 108 + let did = SERVICE_DID; 109 + if (__DEV__) { 110 + did = `${did}%3A${SERVER_PORT.toString()}`; 111 + } 112 + const { success: isDidWeb, data: didWeb } = didWebSchema.safeParse(did); 113 + if (!isDidWeb) return; 114 + const { didDoc } = await buildDidWebDoc(didWeb); 115 + return didDoc; 116 + }; 117 + 118 + export const didDoc = await createDidWebDoc();
+46
src/lib/utils/http/responses.ts
··· 1 + import type { HttpResponseErrorInfo } from "@/lib/types/http/errors"; 2 + import type { 3 + HttpErrorResponse, 4 + HttpResponseData, 5 + HttpSuccessResponse, 6 + } from "@/lib/types/http/responses"; 7 + import { HttpResponseStatusType } from "@/lib/types/http/responses"; 8 + 9 + export interface ResponseOpts { 10 + headers: Record<string, string>; 11 + } 12 + 13 + export const newSuccessResponse = ( 14 + data: HttpResponseData, 15 + options?: ResponseOpts, 16 + ) => { 17 + const body: HttpSuccessResponse = { 18 + status: HttpResponseStatusType.SUCCESS, 19 + data, 20 + }; 21 + return new Response(JSON.stringify(body), { 22 + status: 200, 23 + headers: { 24 + "Content-Type": "application/json", 25 + ...options?.headers, 26 + }, 27 + }); 28 + }; 29 + 30 + export const newErrorResponse = ( 31 + httpCode: number, 32 + errorObj: HttpResponseErrorInfo, 33 + options?: ResponseOpts, 34 + ) => { 35 + const body: HttpErrorResponse = { 36 + status: HttpResponseStatusType.ERROR, 37 + error: errorObj, 38 + }; 39 + return new Response(JSON.stringify(body), { 40 + status: httpCode, 41 + headers: { 42 + "Content-Type": "application/json", 43 + ...options?.headers, 44 + }, 45 + }); 46 + };
+49
src/routes/dot-well-known/did-dot-json/route.ts
··· 1 + import { SERVICE_DID } from "@/lib/env"; 2 + import type { Did } from "@/lib/types/atproto"; 3 + import { didDocumentSchema, didWebSchema } from "@/lib/types/atproto"; 4 + import type { Route, RouteHandler } from "@/lib/types/routes"; 5 + import { didDoc as importedDidDoc } from "@/lib/utils/didDoc"; 6 + import { newErrorResponse } from "@/lib/utils/http/responses"; 7 + import { z } from "zod"; 8 + 9 + const routeHandlerFactory = (did: Did) => { 10 + const serveDidPlc: RouteHandler = async () => { 11 + const plcDirectoryReq = new Request(`https://plc.directory/${did}`); 12 + const plcDirectoryRes = await fetch(plcDirectoryReq); 13 + const { 14 + success, 15 + data: didDocument, 16 + error, 17 + } = didDocumentSchema.safeParse(await plcDirectoryRes.json()); 18 + 19 + if (!success) 20 + return newErrorResponse(500, { 21 + message: 22 + "Parsing the DID document from a public ledger failed. Either the Shard's did:plc is wrong, the did:plc was not registered with a public ledger, or there is something wrong with the public ledger.", 23 + details: z.treeifyError(error), 24 + }); 25 + 26 + return Response.json(didDocument); 27 + }; 28 + 29 + const { success: isDidWeb } = didWebSchema.safeParse(did); 30 + if (!isDidWeb) return serveDidPlc; 31 + 32 + const serveDidDoc: RouteHandler = () => { 33 + const didDoc = importedDidDoc; 34 + if (!didDoc) { 35 + return newErrorResponse(500, { 36 + message: 37 + "Somehow tried to serve a did:web document when no did:web document was available. Specifically, somehow parsing the same SERVICE_DID environment variable resulted in both a did:web and a not did:web", 38 + }); 39 + } 40 + return Response.json(didDoc); 41 + }; 42 + 43 + return serveDidDoc; 44 + }; 45 + 46 + export const didWebDocRoute: Route = { 47 + method: "GET", 48 + handler: routeHandlerFactory(SERVICE_DID), 49 + };
+2
src/routes/index.ts
··· 1 1 import type { Route, WsRoute } from "@/lib/types/routes"; 2 + import { didWebDocRoute } from "@/routes/dot-well-known/did-dot-json/route"; 2 3 import { indexRoute } from "@/routes/route"; 3 4 4 5 export const routes: Record<string, Route | WsRoute> = { 5 6 "/": indexRoute, 7 + "/.well-known/did.json": didWebDocRoute, 6 8 };