decentralised sync engine

feat: serve did doc

serenity 9a85d185 4362aac3

Changed files
+356 -6
src
lib
routes
dot-well-known
did-dot-json
+11
.example.env
···
# port for the lattice server to run on.
# defaults to 7338.
SERVER_PORT="7338"
+
+
# used for verifying inter-service jwts
+
# you *must* specify a did at which this shard may be found. may also include a service identifier.
+
# for more information on the service identifier, you may see https://atproto.com/specs/xrpc#inter-service-authentication-jwt
+
# usually a did:web, but if you're crazy you can put a did:plc, the verifier supports either anyway.
+
# defaults to did:web:localhost
+
SERVICE_DID="did:web:localhost"
+
+
# to tell if you're in dev or prod. defaults to dev.
+
# if running in prod, set to 'production'
+
NODE_ENV="development"
+6 -3
package.json
···
"name": "@gmstn/lattice",
"version": "0.0.1",
"description": "decentralised sync engine",
-
"main": "index.js",
+
"exports": "./index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx src/index.ts",
···
"globals": "^16.4.0",
"jiti": "^2.6.1",
"prettier": "^3.6.2",
-
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0"
},
"dependencies": {
+
"@atcute/crypto": "^2.2.5",
"@fastify/websocket": "^11.2.0",
"dotenv": "^17.2.3",
"fastify": "^5.6.1",
+
"tsx": "^4.20.6",
+
"uint8arrays": "^5.1.0",
"ws": "^8.18.3",
"zod": "^4.1.12"
-
}
+
},
+
"type": "module"
}
+47 -3
pnpm-lock.yaml
···
.:
dependencies:
+
'@atcute/crypto':
+
specifier: ^2.2.5
+
version: 2.2.5
'@fastify/websocket':
specifier: ^11.2.0
version: 11.2.0
···
fastify:
specifier: ^5.6.1
version: 5.6.1
+
tsx:
+
specifier: ^4.20.6
+
version: 4.20.6
+
uint8arrays:
+
specifier: ^5.1.0
+
version: 5.1.0
ws:
specifier: ^8.18.3
version: 8.18.3
···
prettier:
specifier: ^3.6.2
version: 3.6.2
-
tsx:
-
specifier: ^4.20.6
-
version: 4.20.6
typescript:
specifier: ^5.9.3
version: 5.9.3
···
version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
packages:
+
+
'@atcute/crypto@2.2.5':
+
resolution: {integrity: sha512-9CbQ9cJ68XewsbLrgdmWQS2uDD9D0hizWFJ3OOZ16TCuARREmzKEpFgHlMxPswR3bDxjwfiXzmYUlHaTqsnxRQ==}
+
+
'@atcute/multibase@1.1.6':
+
resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==}
+
+
'@atcute/uint8array@1.0.5':
+
resolution: {integrity: sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q==}
'@esbuild/aix-ppc64@0.25.11':
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
···
'@humanwhocodes/retry@0.4.3':
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+
+
'@noble/secp256k1@2.3.0':
+
resolution: {integrity: sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
···
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
multiformats@13.4.1:
+
resolution: {integrity: sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q==}
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
···
engines: {node: '>=14.17'}
hasBin: true
+
uint8arrays@5.1.0:
+
resolution: {integrity: sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==}
+
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
···
snapshots:
+
'@atcute/crypto@2.2.5':
+
dependencies:
+
'@atcute/multibase': 1.1.6
+
'@atcute/uint8array': 1.0.5
+
'@noble/secp256k1': 2.3.0
+
+
'@atcute/multibase@1.1.6':
+
dependencies:
+
'@atcute/uint8array': 1.0.5
+
+
'@atcute/uint8array@1.0.5': {}
+
'@esbuild/aix-ppc64@0.25.11':
optional: true
···
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
+
+
'@noble/secp256k1@2.3.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
···
ms@2.1.3: {}
+
multiformats@13.4.1: {}
+
natural-compare@1.4.0: {}
on-exit-leak-free@2.1.2: {}
···
- supports-color
typescript@5.9.3: {}
+
+
uint8arrays@5.1.0:
+
dependencies:
+
multiformats: 13.4.1
undici-types@7.14.0: {}
+15
src/lib/env.ts
···
+
import { didSchema } from "@/lib/types/atproto";
import "dotenv/config";
const nodeEnv = process.env.NODE_ENV;
···
"Environment variable SERVER_PORT not set. Defaulting to 7338",
);
export const SERVER_PORT = Number.parseInt(serverPort ?? "7338");
+
+
const serviceDid = process.env.SERVICE_DID;
+
const {
+
success: serviceDidParseSuccess,
+
error: serviceDidParseError,
+
data: serviceDidParsed,
+
} = didSchema.safeParse(serviceDid);
+
if (!serviceDidParseSuccess) {
+
console.warn(serviceDidParseError);
+
console.warn(
+
"Environment variable SERVICE_DID not set. Defaulting to `did:web:localhost`",
+
);
+
}
+
export const SERVICE_DID = serviceDidParsed ?? "did:web:localhost";
+19
src/lib/types/http/errors.ts
···
+
import { z } from "zod";
+
+
export const HttpGeneralErrorType = {
+
TYPE_ERROR: "Type error",
+
PARAMS_ERROR: "Missing required params",
+
SERVER_ERROR: "Something went wrong on the server",
+
};
+
export const httpGeneralErrorTypeSchema = z.enum(HttpGeneralErrorType);
+
export type HttpGeneralErrorType = z.infer<typeof httpGeneralErrorTypeSchema>;
+
+
export const httpErrorTypeSchema = z.union([httpGeneralErrorTypeSchema]);
+
export type HttpErrorType = z.infer<typeof httpErrorTypeSchema>;
+
+
export const httpResponseErrorInfoSchema = z.object({
+
message: z.string(),
+
type: z.optional(httpErrorTypeSchema),
+
details: z.optional(z.unknown()),
+
});
+
export type HttpResponseErrorInfo = z.infer<typeof httpResponseErrorInfoSchema>;
+43
src/lib/types/http/responses.ts
···
+
import z from "zod";
+
import { httpResponseErrorInfoSchema } from "@/lib/types/http/errors";
+
+
export const HttpResponseStatusType = {
+
SUCCESS: "success",
+
ERROR: "error",
+
} as const;
+
export const httpResponseStatusTypeSchema = z.enum(HttpResponseStatusType);
+
export type HttpResponseStatusType = z.infer<
+
typeof httpResponseStatusTypeSchema
+
>;
+
+
export const handshakeResponseSchema = z.object({
+
sessionInfo: z.unknown(),
+
});
+
export type HandshakeResponse = z.infer<typeof handshakeResponseSchema>;
+
+
export const httpResponseDataSchema = z.union([handshakeResponseSchema]);
+
export type HttpResponseData = z.infer<typeof httpResponseDataSchema>;
+
+
const httpResponseBaseSchema = z.object({
+
status: httpResponseStatusTypeSchema,
+
data: z.optional(httpResponseDataSchema),
+
error: z.optional(httpResponseErrorInfoSchema),
+
});
+
+
export const httpSuccessResponseSchema = httpResponseBaseSchema
+
.safeExtend({
+
status: z.literal(HttpResponseStatusType.SUCCESS),
+
data: httpResponseDataSchema,
+
error: z.undefined(),
+
})
+
.omit({ error: true });
+
export type HttpSuccessResponse = z.infer<typeof httpSuccessResponseSchema>;
+
+
export const httpErrorResponseSchema = httpResponseBaseSchema
+
.safeExtend({
+
status: z.literal(HttpResponseStatusType.ERROR),
+
error: httpResponseErrorInfoSchema,
+
data: z.undefined(),
+
})
+
.omit({ data: true });
+
export type HttpErrorResponse = z.infer<typeof httpErrorResponseSchema>;
+118
src/lib/utils/didDoc.ts
···
+
import { __DEV__, SERVER_PORT, SERVICE_DID } from "@/lib/env";
+
import {
+
didWebSchema,
+
type DidDocument,
+
type DidWeb,
+
type VerificationMethod,
+
} from "@/lib/types/atproto";
+
import { Secp256k1PrivateKeyExportable } from "@atcute/crypto";
+
import { toString as uint8arraysToString } from "uint8arrays";
+
+
export interface ServiceKeys {
+
atproto: Secp256k1PrivateKeyExportable;
+
service: Secp256k1PrivateKeyExportable;
+
}
+
+
export interface CreateDidWebDocResult {
+
didDoc: DidDocument;
+
keys: ServiceKeys;
+
}
+
+
const buildDidWebDoc = async (
+
didWeb: DidWeb,
+
): Promise<CreateDidWebDocResult> => {
+
const atprotoKey = await Secp256k1PrivateKeyExportable.createKeypair();
+
const serviceKey = await Secp256k1PrivateKeyExportable.createKeypair();
+
+
const atprotoMultikey = encodeMultikey(
+
await atprotoKey.exportPublicKey("raw"),
+
);
+
const serviceMultikey = encodeMultikey(
+
await atprotoKey.exportPublicKey("raw"),
+
);
+
+
const { domain, serviceEndpoint } = extractInfoFromDidWeb(didWeb);
+
+
const verificationMethod: Array<VerificationMethod> = [
+
{
+
id: `${didWeb}#atproto`,
+
type: "Multikey",
+
controller: didWeb,
+
publicKeyMultibase: atprotoMultikey,
+
},
+
];
+
+
const didDoc: DidDocument = {
+
"@context": [
+
"https://www.w3.org/ns/did/v1",
+
"https://w3id.org/security/multikey/v1",
+
"https://w3id.org/security/suites/secp256k1-2019/v1",
+
],
+
id: didWeb,
+
verificationMethod,
+
};
+
+
if (serviceEndpoint) {
+
const serviceEndpointType = "GemstoneShard";
+
+
const serviceEndpointUrl = `https://${domain}/`;
+
+
// @ts-expect-error we are already adding the verificationMethod array above when we create didDoc.
+
didDoc.verificationMethod.push({
+
id: `${didWeb}#${serviceEndpoint}`,
+
type: "Multikey",
+
controller: didWeb,
+
publicKeyMultibase: serviceMultikey,
+
});
+
+
didDoc.service = [
+
{
+
id: `${didWeb}#${serviceEndpoint}`,
+
type: serviceEndpointType,
+
serviceEndpoint: serviceEndpointUrl,
+
},
+
];
+
}
+
+
return {
+
didDoc,
+
keys: {
+
atproto: atprotoKey,
+
service: serviceKey,
+
},
+
};
+
};
+
+
const encodeMultikey = (publicKeyBytes: Uint8Array) => {
+
// For secp256k1 (K-256), prefix with 0xE701
+
const prefixed = new Uint8Array(publicKeyBytes.length + 2);
+
prefixed[0] = 0xe7;
+
prefixed[1] = 0x01;
+
prefixed.set(publicKeyBytes, 2);
+
+
// Base58-btc encode with 'z' prefix
+
const value = uint8arraysToString(prefixed, "base58btc");
+
+
return "z" + value;
+
};
+
+
const extractInfoFromDidWeb = (didWeb: DidWeb) => {
+
const fragments = didWeb.split("#");
+
return {
+
domain: fragments[0].replace("did:web:", ""),
+
serviceEndpoint: fragments[1] as string | undefined,
+
};
+
};
+
+
const createDidWebDoc = async () => {
+
let did = SERVICE_DID;
+
if (__DEV__) {
+
did = `${did}%3A${SERVER_PORT.toString()}`;
+
}
+
const { success: isDidWeb, data: didWeb } = didWebSchema.safeParse(did);
+
if (!isDidWeb) return;
+
const { didDoc } = await buildDidWebDoc(didWeb);
+
return didDoc;
+
};
+
+
export const didDoc = await createDidWebDoc();
+46
src/lib/utils/http/responses.ts
···
+
import type { HttpResponseErrorInfo } from "@/lib/types/http/errors";
+
import type {
+
HttpErrorResponse,
+
HttpResponseData,
+
HttpSuccessResponse,
+
} from "@/lib/types/http/responses";
+
import { HttpResponseStatusType } from "@/lib/types/http/responses";
+
+
export interface ResponseOpts {
+
headers: Record<string, string>;
+
}
+
+
export const newSuccessResponse = (
+
data: HttpResponseData,
+
options?: ResponseOpts,
+
) => {
+
const body: HttpSuccessResponse = {
+
status: HttpResponseStatusType.SUCCESS,
+
data,
+
};
+
return new Response(JSON.stringify(body), {
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...options?.headers,
+
},
+
});
+
};
+
+
export const newErrorResponse = (
+
httpCode: number,
+
errorObj: HttpResponseErrorInfo,
+
options?: ResponseOpts,
+
) => {
+
const body: HttpErrorResponse = {
+
status: HttpResponseStatusType.ERROR,
+
error: errorObj,
+
};
+
return new Response(JSON.stringify(body), {
+
status: httpCode,
+
headers: {
+
"Content-Type": "application/json",
+
...options?.headers,
+
},
+
});
+
};
+49
src/routes/dot-well-known/did-dot-json/route.ts
···
+
import { SERVICE_DID } from "@/lib/env";
+
import type { Did } from "@/lib/types/atproto";
+
import { didDocumentSchema, didWebSchema } from "@/lib/types/atproto";
+
import type { Route, RouteHandler } from "@/lib/types/routes";
+
import { didDoc as importedDidDoc } from "@/lib/utils/didDoc";
+
import { newErrorResponse } from "@/lib/utils/http/responses";
+
import { z } from "zod";
+
+
const routeHandlerFactory = (did: Did) => {
+
const serveDidPlc: RouteHandler = async () => {
+
const plcDirectoryReq = new Request(`https://plc.directory/${did}`);
+
const plcDirectoryRes = await fetch(plcDirectoryReq);
+
const {
+
success,
+
data: didDocument,
+
error,
+
} = didDocumentSchema.safeParse(await plcDirectoryRes.json());
+
+
if (!success)
+
return newErrorResponse(500, {
+
message:
+
"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.",
+
details: z.treeifyError(error),
+
});
+
+
return Response.json(didDocument);
+
};
+
+
const { success: isDidWeb } = didWebSchema.safeParse(did);
+
if (!isDidWeb) return serveDidPlc;
+
+
const serveDidDoc: RouteHandler = () => {
+
const didDoc = importedDidDoc;
+
if (!didDoc) {
+
return newErrorResponse(500, {
+
message:
+
"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",
+
});
+
}
+
return Response.json(didDoc);
+
};
+
+
return serveDidDoc;
+
};
+
+
export const didWebDocRoute: Route = {
+
method: "GET",
+
handler: routeHandlerFactory(SERVICE_DID),
+
};
+2
src/routes/index.ts
···
import type { Route, WsRoute } from "@/lib/types/routes";
+
import { didWebDocRoute } from "@/routes/dot-well-known/did-dot-json/route";
import { indexRoute } from "@/routes/route";
export const routes: Record<string, Route | WsRoute> = {
"/": indexRoute,
+
"/.well-known/did.json": didWebDocRoute,
};