diff --git a/CHANGES.md b/CHANGES.md index 2c6319d47..ada3900cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,6 +17,17 @@ To be released. runtimes such as Cloudflare Workers, which may terminate an isolate once the error response is returned. This applies both to `immediate: true` and to federations without an outbox queue. [[#1380], [#1388]] + - Fixed `CryptoKey` and `CryptoKeyPair` values obtained from `node:crypto` + being rejected by `ActorKeyPairsDispatcher`, `SharedInboxKeyDispatcher`, + `SenderKeyPair`, `signRequest()`, `createProof()`, `signObject()`, + `createSignature()`, `signJsonLd()`, `exportJwk()`, and the authenticated + document loader APIs when `@types/node` 24.7.0 or later is installed. + Those APIs now accept a structural key type that both Node.js and + DOM/Web Worker keys satisfy, while the keys returned by Fedify keep the + ambient `CryptoKey` types. `SenderKeyPair` and + `SharedInboxKeyDispatcher` take a type parameter that defaults to + `CryptoKey`, so code that reads a key pair or calls a dispatcher keeps the + narrower type. [[#554], [#1391]] - Fixed Linked Data Signatures being emitted with a JSON-LD context that no longer resolves. The attached `signature` object now uses `https://w3id.org/security/v1` instead of @@ -57,6 +68,7 @@ To be released. configure only `queue: { fanout: ... }` no longer need to add an unused `inbox` or `outbox` queue to make fanout delivery work. [[#1389], [#1392]] +[#554]: https://github.com/fedify-dev/fedify/issues/554 [#664]: https://github.com/fedify-dev/fedify/issues/664 [#1339]: https://github.com/fedify-dev/fedify/issues/1339 [#1347]: https://github.com/fedify-dev/fedify/pull/1347 @@ -65,10 +77,16 @@ To be released. [#1388]: https://github.com/fedify-dev/fedify/pull/1388 [#1389]: https://github.com/fedify-dev/fedify/issues/1389 [#1390]: https://github.com/fedify-dev/fedify/pull/1390 +[#1391]: https://github.com/fedify-dev/fedify/pull/1391 [#1392]: https://github.com/fedify-dev/fedify/pull/1392 ### @fedify/vocab + - Fixed `CryptographicKey` and `Multikey` rejecting a `publicKey` obtained + from `node:crypto` in their constructors and `clone()` when `@types/node` + 24.7.0 or later is installed. The property accepts a structural key type + now, while the getters still return the ambient `CryptoKey` type. + [[#554], [#1391]] - Fixed parsing of date-times without seconds, such as `2024-01-01T10:00Z`, `2024-01-01T10:00+09:00`, or `2024-01-01T10:00.5Z`, which Activity Streams 2.0 allows. They raised a `RangeError`, or were ignored in @@ -90,9 +108,20 @@ To be released. literals the same way. It treats a literal without a UTC offset as UTC and handles literals without seconds, including those with a fraction. [[#1339], [#1347] by ChanHaeng Lee\] + - Added the `CryptoKeyLike` and `CryptoKeyPairLike` types for the structural + key shape that Fedify APIs accept. They cover keys from Node.js, + DOM/Web Workers, and Cloudflare Workers, so `exportSpki()`, + `exportMultibaseKey()`, and `getAuthenticatedDocumentLoader()` no longer + reject keys obtained from `node:crypto` with `@types/node` 24.7.0 or later. + `AuthenticatedDocumentLoaderFactory` keeps the ambient `CryptoKey` type on + the identity it receives, so existing implementations do not have to + change. [[#554], [#1391]] ### @fedify/vocab-tools + - Changed the code generated by `@fedify/vocab-tools` to import the + `CryptoKeyLike` type from `@fedify/vocab-runtime`. Regenerated code now + requires `@fedify/vocab-runtime` 2.0.36 or later. [[#554], [#1391]] - Fixed generated `xsd:dateTime` decoders failing on date-times without seconds, such as `2024-01-01T10:00Z` or `2024-01-01T10:00.5Z`. Regenerate your vocabulary code to get the fix. Generated code now imports diff --git a/changes.d/fedify/node-webcrypto-keys.md b/changes.d/fedify/node-webcrypto-keys.md new file mode 100644 index 000000000..618568163 --- /dev/null +++ b/changes.d/fedify/node-webcrypto-keys.md @@ -0,0 +1,16 @@ +--- +links: + '#1391': https://github.com/fedify-dev/fedify/pull/1391 + '#554': https://github.com/fedify-dev/fedify/issues/554 +--- + - Fixed `CryptoKey` and `CryptoKeyPair` values obtained from `node:crypto` + being rejected by `ActorKeyPairsDispatcher`, `SharedInboxKeyDispatcher`, + `SenderKeyPair`, `signRequest()`, `createProof()`, `signObject()`, + `createSignature()`, `signJsonLd()`, `exportJwk()`, and the authenticated + document loader APIs when `@types/node` 24.7.0 or later is installed. + Those APIs now accept a structural key type that both Node.js and + DOM/Web Worker keys satisfy, while the keys returned by Fedify keep the + ambient `CryptoKey` types. `SenderKeyPair` and + `SharedInboxKeyDispatcher` take a type parameter that defaults to + `CryptoKey`, so code that reads a key pair or calls a dispatcher keeps the + narrower type. [[#554], [#1391]] diff --git a/changes.d/vocab-runtime/node-webcrypto-keys.md b/changes.d/vocab-runtime/node-webcrypto-keys.md new file mode 100644 index 000000000..88f31bf05 --- /dev/null +++ b/changes.d/vocab-runtime/node-webcrypto-keys.md @@ -0,0 +1,13 @@ +--- +links: + '#1391': https://github.com/fedify-dev/fedify/pull/1391 + '#554': https://github.com/fedify-dev/fedify/issues/554 +--- + - Added the `CryptoKeyLike` and `CryptoKeyPairLike` types for the structural + key shape that Fedify APIs accept. They cover keys from Node.js, + DOM/Web Workers, and Cloudflare Workers, so `exportSpki()`, + `exportMultibaseKey()`, and `getAuthenticatedDocumentLoader()` no longer + reject keys obtained from `node:crypto` with `@types/node` 24.7.0 or later. + `AuthenticatedDocumentLoaderFactory` keeps the ambient `CryptoKey` type on + the identity it receives, so existing implementations do not have to + change. [[#554], [#1391]] diff --git a/changes.d/vocab-tools/node-webcrypto-keys.md b/changes.d/vocab-tools/node-webcrypto-keys.md new file mode 100644 index 000000000..bc694a24c --- /dev/null +++ b/changes.d/vocab-tools/node-webcrypto-keys.md @@ -0,0 +1,8 @@ +--- +links: + '#1391': https://github.com/fedify-dev/fedify/pull/1391 + '#554': https://github.com/fedify-dev/fedify/issues/554 +--- + - Changed the code generated by `@fedify/vocab-tools` to import the + `CryptoKeyLike` type from `@fedify/vocab-runtime`. Regenerated code now + requires `@fedify/vocab-runtime` 2.0.36 or later. [[#554], [#1391]] diff --git a/changes.d/vocab/node-webcrypto-keys.md b/changes.d/vocab/node-webcrypto-keys.md new file mode 100644 index 000000000..56b603704 --- /dev/null +++ b/changes.d/vocab/node-webcrypto-keys.md @@ -0,0 +1,10 @@ +--- +links: + '#1391': https://github.com/fedify-dev/fedify/pull/1391 + '#554': https://github.com/fedify-dev/fedify/issues/554 +--- + - Fixed `CryptographicKey` and `Multikey` rejecting a `publicKey` obtained + from `node:crypto` in their constructors and `clone()` when `@types/node` + 24.7.0 or later is installed. The property accepts a structural key type + now, while the getters still return the ambient `CryptoKey` type. + [[#554], [#1391]] diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index f235da4b5..923068a1c 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -7,6 +7,7 @@ import type { Recipient, } from "@fedify/vocab"; import { getTypeId } from "@fedify/vocab"; +import type { CryptoKeyLike, CryptoKeyPairLike } from "@fedify/vocab-runtime"; import { getLogger } from "@logtape/logtape"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import type { Tracer } from "@opentelemetry/api"; @@ -107,7 +108,10 @@ export class FederationBuilderImpl >; inboxListeners?: InboxListenerSet; inboxErrorHandler?: InboxErrorHandler; - sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher; + sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher< + TContextData, + CryptoKeyLike + >; outboxPermanentFailureHandler?: OutboxPermanentFailureHandler; idempotencyStrategy?: | IdempotencyStrategy @@ -445,7 +449,7 @@ export class FederationBuilderImpl this.actorCallbacks = callbacks; const setters: ActorCallbackSetters = { setKeyPairsDispatcher: ( - dispatcher: ActorKeyPairsDispatcher, + dispatcher: ActorKeyPairsDispatcher, ) => { callbacks.keyPairsDispatcher = (ctx, identifier) => this._getTracer().startActiveSpan( @@ -1156,7 +1160,7 @@ export class FederationBuilderImpl return setters; }, setSharedKeyDispatcher: ( - dispatcher: SharedInboxKeyDispatcher, + dispatcher: SharedInboxKeyDispatcher, ): InboxListenerSetters => { this.sharedInboxKeyDispatcher = dispatcher; return setters; @@ -1535,7 +1539,7 @@ export function createFederationBuilder(): FederationBuilder< interface ActorCallbacks { dispatcher?: ActorDispatcher; - keyPairsDispatcher?: ActorKeyPairsDispatcher; + keyPairsDispatcher?: ActorKeyPairsDispatcher; handleMapper?: ActorHandleMapper; aliasMapper?: ActorAliasMapper; authorizePredicate?: AuthorizePredicate; diff --git a/packages/fedify/src/federation/callback.ts b/packages/fedify/src/federation/callback.ts index 94bc12f75..b2a6991be 100644 --- a/packages/fedify/src/federation/callback.ts +++ b/packages/fedify/src/federation/callback.ts @@ -1,4 +1,5 @@ import type { Activity, Actor, Object } from "@fedify/vocab"; +import type { CryptoKeyLike, CryptoKeyPairLike } from "@fedify/vocab-runtime"; import type { Link } from "@fedify/webfinger"; import type { NodeInfo } from "../nodeinfo/types.ts"; import type { PageItems } from "./collection.ts"; @@ -41,16 +42,25 @@ export type ActorDispatcher = ( /** * A callback that dispatches key pairs for an actor. * + * The returned key pairs may come from any supported runtime, including keys + * obtained from `node:crypto`'s Web Crypto API, so the key pair type is + * parameterized. It defaults to the ambient `CryptoKeyPair` type for + * compatibility with code that calls a dispatcher directly. + * * @template TContextData The context data to pass to the {@link Context}. + * @template TKeyPair The type of the key pairs the dispatcher returns. * @param context The context. * @param identifier The actor's internal identifier or username. * @returns The key pairs. * @since 0.10.0 */ -export type ActorKeyPairsDispatcher = ( +export type ActorKeyPairsDispatcher< + TContextData, + TKeyPair extends CryptoKeyPairLike = CryptoKeyPair, +> = ( context: Context, identifier: string, -) => CryptoKeyPair[] | Promise; +) => TKeyPair[] | Promise; /** * A callback that maps a WebFinger username to the corresponding actor's @@ -203,15 +213,18 @@ export type InboxErrorHandler = ( * not authorized. * @since 0.11.0 */ -export type SharedInboxKeyDispatcher = ( +export type SharedInboxKeyDispatcher< + TContextData, + TKeyPair extends CryptoKeyLike = CryptoKey, +> = ( context: Context, ) => - | SenderKeyPair + | SenderKeyPair | { identifier: string } | { username: string } | null | Promise< - | SenderKeyPair + | SenderKeyPair | { identifier: string } | { username: string } | null diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 0c006d0ce..737498472 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -10,7 +10,7 @@ import type { Recipient, TraverseCollectionOptions, } from "@fedify/vocab"; -import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { CryptoKeyLike, DocumentLoader } from "@fedify/vocab-runtime"; import type { LookupWebFingerOptions, ResourceDescriptor, @@ -239,7 +239,7 @@ export interface Context { * @since 0.4.0 */ getDocumentLoader( - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, ): DocumentLoader; /** @@ -371,8 +371,8 @@ export interface Context { */ sendActivity( sender: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[], @@ -624,8 +624,8 @@ export interface InboxContext extends Context { */ forwardActivity( forwarder: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[], diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 7b7f523c5..a65de4e47 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -7,6 +7,8 @@ import type { } from "@fedify/vocab"; import type { AuthenticatedDocumentLoaderFactory, + CryptoKeyLike, + CryptoKeyPairLike, DocumentLoaderFactory, GetUserAgentOptions, } from "@fedify/vocab-runtime"; @@ -1001,7 +1003,7 @@ export interface ActorCallbackSetters { * @since 0.10.0 */ setKeyPairsDispatcher( - dispatcher: ActorKeyPairsDispatcher, + dispatcher: ActorKeyPairsDispatcher, ): ActorCallbackSetters; /** @@ -1187,7 +1189,7 @@ export interface InboxListenerSetters { * @since 0.11.0 */ setSharedKeyDispatcher( - dispatcher: SharedInboxKeyDispatcher, + dispatcher: SharedInboxKeyDispatcher, ): InboxListenerSetters; /** diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index f78da81ba..e19be6fc3 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -16,6 +16,8 @@ import { Multikey, traverseCollection, } from "@fedify/vocab"; +import type { CryptoKeyLike, CryptoKeyPairLike } from "@fedify/vocab-runtime"; +import { asCryptoKey } from "@fedify/vocab-runtime/internal"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoader, @@ -1365,7 +1367,7 @@ export class FederationImpl } async sendActivity( - keys: SenderKeyPair[], + keys: SenderKeyPair[], inboxes: Record< string, { actorIds: Iterable; sharedInbox: boolean } @@ -1390,7 +1392,7 @@ export class FederationImpl ); const activityId = activity.id.href; let proofCreated = false; - let rsaKey: { keyId: URL; privateKey: CryptoKey } | null = null; + let rsaKey: { keyId: URL; privateKey: CryptoKeyLike } | null = null; for (const { keyId, privateKey } of keys) { validateCryptoKey(privateKey, "private"); if (rsaKey == null && privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") { @@ -2309,7 +2311,7 @@ export class ContextImpl implements Context { }, ); } - let keyPairs: (CryptoKeyPair & { keyId: URL })[]; + let keyPairs: (CryptoKeyPairLike & { keyId: URL })[]; try { keyPairs = await this.getKeyPairsFromIdentifier(identifier); } catch (_) { @@ -2322,6 +2324,8 @@ export class ContextImpl implements Context { for (const keyPair of keyPairs) { const newPair: ActorKeyPair = { ...keyPair, + privateKey: asCryptoKey(keyPair.privateKey), + publicKey: asCryptoKey(keyPair.publicKey), cryptographicKey: new CryptographicKey({ id: keyPair.keyId, owner, @@ -2341,7 +2345,7 @@ export class ContextImpl implements Context { protected async getKeyPairsFromIdentifier( identifier: string, - ): Promise<(CryptoKeyPair & { keyId: URL })[]> { + ): Promise<(CryptoKeyPairLike & { keyId: URL })[]> { const logger = getLogger(["fedify", "federation", "actor"]); if (this.federation.actorCallbacks?.keyPairsDispatcher == null) { throw new Error("No actor key pairs dispatcher registered."); @@ -2384,7 +2388,7 @@ export class ContextImpl implements Context { protected async getRsaKeyPairFromIdentifier( identifier: string, - ): Promise { + ): Promise { const keyPairs = await this.getKeyPairsFromIdentifier(identifier); for (const keyPair of keyPairs) { const { privateKey } = keyPair; @@ -2409,10 +2413,10 @@ export class ContextImpl implements Context { | { identifier: string } | { username: string }, ): Promise; - getDocumentLoader(identity: SenderKeyPair): DocumentLoader; + getDocumentLoader(identity: SenderKeyPair): DocumentLoader; getDocumentLoader( identity: - | SenderKeyPair + | SenderKeyPair | { identifier: string } | { username: string }, ): DocumentLoader | Promise { @@ -2440,11 +2444,17 @@ export class ContextImpl implements Context { return keyPair.then((pair) => pair == null ? this.documentLoader - : this.federation.authenticatedDocumentLoaderFactory(pair) + : this.federation.authenticatedDocumentLoaderFactory({ + keyId: pair.keyId, + privateKey: asCryptoKey(pair.privateKey), + }) ); }); } - return this.federation.authenticatedDocumentLoaderFactory(identity); + return this.federation.authenticatedDocumentLoaderFactory({ + keyId: identity.keyId, + privateKey: asCryptoKey(identity.privateKey), + }); } lookupObject( @@ -2514,8 +2524,8 @@ export class ContextImpl implements Context { async sendActivity( sender: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[] | "followers", @@ -2586,8 +2596,8 @@ export class ContextImpl implements Context { protected async sendActivityInternal( sender: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[] | "followers", @@ -2597,7 +2607,7 @@ export class ContextImpl implements Context { activityJsonLd?: unknown, ): Promise { const logger = getLogger(["fedify", "federation", "outbox"]); - let keys: SenderKeyPair[]; + let keys: SenderKeyPair[]; let identifier: string | null = null; let actorKeyPairs: ActorKeyPair[] | null = null; if ("identifier" in sender || "username" in sender) { @@ -3291,8 +3301,8 @@ export class InboxContextImpl extends ContextImpl forwardActivity( forwarder: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[], @@ -3307,8 +3317,8 @@ export class InboxContextImpl extends ContextImpl ): Promise; forwardActivity( forwarder: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[] | "followers", @@ -3344,15 +3354,15 @@ export class InboxContextImpl extends ContextImpl private async forwardActivityInternal( forwarder: - | SenderKeyPair - | SenderKeyPair[] + | SenderKeyPair + | SenderKeyPair[] | { identifier: string } | { username: string }, recipients: Recipient | Recipient[] | "followers", options?: ForwardActivityOptions, ): Promise { const logger = getLogger(["fedify", "federation", "inbox"]); - let keys: SenderKeyPair[]; + let keys: SenderKeyPair[]; let identifier: string | null = null; if ( "identifier" in forwarder || "username" in forwarder diff --git a/packages/fedify/src/federation/send.ts b/packages/fedify/src/federation/send.ts index ce7d51a96..375057992 100644 --- a/packages/fedify/src/federation/send.ts +++ b/packages/fedify/src/federation/send.ts @@ -1,5 +1,9 @@ import type { Recipient } from "@fedify/vocab"; -import { FetchError, UrlError } from "@fedify/vocab-runtime"; +import { + type CryptoKeyLike, + FetchError, + UrlError, +} from "@fedify/vocab-runtime"; import { openDirectFetch, openPublicFetch, @@ -84,13 +88,18 @@ export function extractInboxes( /** * A key pair for an actor who sends an activity. + * + * The private key type is parameterized so that Fedify can accept keys from + * `node:crypto` at its input boundaries while `SenderKeyPair` still exposes the + * ambient `CryptoKey` type by default. + * @template TKey The type of the private key. * @since 0.10.0 */ -export interface SenderKeyPair { +export interface SenderKeyPair { /** * The actor's private key to sign the request. */ - readonly privateKey: CryptoKey; + readonly privateKey: TKey; /** * The public key ID that corresponds to the private key. @@ -123,7 +132,7 @@ export interface SendActivityParameters { * The key pairs of the sender to sign the request. It must not be empty. * @since 0.10.0 */ - readonly keys: readonly SenderKeyPair[]; + readonly keys: readonly SenderKeyPair[]; /** * The inbox URL to send the activity to. @@ -318,7 +327,7 @@ async function sendActivityInternal( body: JSON.stringify(activity), redirect: "manual", }); - let rsaKey: SenderKeyPair | null = null; + let rsaKey: SenderKeyPair | null = null; for (const key of keys) { if (key.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") { rsaKey = key; diff --git a/packages/fedify/src/sig/http.ts b/packages/fedify/src/sig/http.ts index 58aa0ad0d..7de3cca5d 100644 --- a/packages/fedify/src/sig/http.ts +++ b/packages/fedify/src/sig/http.ts @@ -1,6 +1,13 @@ import { CryptographicKey } from "@fedify/vocab"; -import { type DocumentLoader, FetchError } from "@fedify/vocab-runtime"; -import type { PublicFetchSession } from "@fedify/vocab-runtime/internal"; +import { + type CryptoKeyLike, + type DocumentLoader, + FetchError, +} from "@fedify/vocab-runtime"; +import { + asCryptoKey, + type PublicFetchSession, +} from "@fedify/vocab-runtime/internal"; import { getLogger } from "@logtape/logtape"; import { type Span, @@ -86,11 +93,12 @@ export interface SignRequestOptions { */ export async function signRequest( request: Request, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, options: SignRequestOptions = {}, ): Promise { validateCryptoKey(privateKey, "private"); + const signingKey = asCryptoKey(privateKey); const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer( metadata.name, @@ -108,7 +116,7 @@ export async function signRequest( // Pass through test options if provided signed = await signRequestRfc9421( request, - privateKey, + signingKey, keyId, span, options.currentTime, @@ -118,7 +126,7 @@ export async function signRequest( // Default to draft-cavage signed = await signRequestDraft( request, - privateKey, + signingKey, keyId, span, options.currentTime, @@ -1478,7 +1486,7 @@ interface DoubleKnockHop { */ export async function doubleKnock( request: Request, - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, options: DoubleKnockOptions = {}, ): Promise { const { response } = await doubleKnockCore( @@ -1506,7 +1514,7 @@ export async function doubleKnock( */ export async function doubleKnockScoped( request: Request, - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, options: DoubleKnockOptions, openHop: DoubleKnockOpenHop, consume: (response: Response) => Promise, @@ -1570,7 +1578,7 @@ function cancelResponseBody(response: Response, url: string): void { async function doubleKnockCore( request: Request, - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, options: DoubleKnockOptions, openHop: DoubleKnockOpenHop, ): Promise { diff --git a/packages/fedify/src/sig/key-types.test.ts b/packages/fedify/src/sig/key-types.test.ts new file mode 100644 index 000000000..0f1af8fdb --- /dev/null +++ b/packages/fedify/src/sig/key-types.test.ts @@ -0,0 +1,120 @@ +import { test } from "@fedify/fixture"; +import type { CryptographicKey, Multikey } from "@fedify/vocab"; +import type { + AuthenticatedDocumentLoaderFactory, + CryptoKeyLike, + CryptoKeyPairLike, + exportMultibaseKey, + exportSpki, +} from "@fedify/vocab-runtime"; +import type { ActorKeyPairsDispatcher } from "../federation/callback.ts"; +import type { Context } from "../federation/context.ts"; +import type { + ActorCallbackSetters, + InboxListenerSetters, +} from "../federation/federation.ts"; +import type { SenderKeyPair } from "../federation/send.ts"; +import type { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; +import type { signRequest } from "./http.ts"; +import type { createSignature, signJsonLd } from "./ld.ts"; +import type { createProof, signObject } from "./proof.ts"; +import type { exportJwk } from "./key.ts"; + +/** + * Asserts at compile time that `T` is assignable to `U`. + */ +type AssertAssignable = T; + +/** + * The key usages Node.js adds on top of the DOM and Web Worker ones since + * Node.js 24.7, when ML-KEM support was added to its Web Crypto API. + */ +type NodeKeyUsage = + | KeyUsage + | "encapsulateBits" + | "decapsulateBits" + | "encapsulateKey" + | "decapsulateKey"; + +/** + * Mirrors `import("node:crypto").webcrypto.CryptoKey`, whose `usages` array + * has a wider element type than the ambient `CryptoKey`. + */ +interface NodeCryptoKey extends Omit { + readonly usages: NodeKeyUsage[]; +} + +/** + * Mirrors `import("node:crypto").webcrypto.CryptoKeyPair`. + */ +interface NodeCryptoKeyPair { + readonly privateKey: NodeCryptoKey; + readonly publicKey: NodeCryptoKey; +} + +/** + * These are compile-time assertions. A regression in any of the public + * signatures fails `deno check` even though nothing here runs. + */ +export type NodeKeyTypeChecks = [ + AssertAssignable, + AssertAssignable, + AssertAssignable< + ( + context: Context, + identifier: string, + ) => NodeCryptoKeyPair[] | Promise, + ActorKeyPairsDispatcher + >, + AssertAssignable< + ActorKeyPairsDispatcher, + Parameters["setKeyPairsDispatcher"]>[0] + >, + AssertAssignable, + AssertAssignable["privateKey"]>, + AssertAssignable< + (context: Context) => SenderKeyPair, + Parameters["setSharedKeyDispatcher"]>[0] + >, + AssertAssignable< + Parameters[0]["privateKey"], + CryptoKey + >, + AssertAssignable< + NodeCryptoKey, + Parameters[0]["privateKey"] + >, + AssertAssignable[1]>, + AssertAssignable[1]>, + AssertAssignable[1]>, + AssertAssignable[1]>, + AssertAssignable[1]>, + AssertAssignable[0]>, + AssertAssignable[0]>, + AssertAssignable[0]>, + AssertAssignable< + NodeCryptoKey, + NonNullable< + ConstructorParameters[0]["publicKey"] + > + >, + AssertAssignable< + NodeCryptoKey, + NonNullable[0]["publicKey"]> + >, + AssertAssignable< + NodeCryptoKey, + NonNullable< + NonNullable[0]>["publicKey"] + > + >, + AssertAssignable< + NodeCryptoKey, + NonNullable[0]>["publicKey"]> + >, +]; + +test("Node.js Web Crypto keys are accepted by the public key types", () => { + // The actual assertions are the type aliases above; this keeps the test + // runner from dropping a file that contains only type-level checks. +}); diff --git a/packages/fedify/src/sig/key.ts b/packages/fedify/src/sig/key.ts index ccb14bb91..2c881d440 100644 --- a/packages/fedify/src/sig/key.ts +++ b/packages/fedify/src/sig/key.ts @@ -6,11 +6,13 @@ import { Object, } from "@fedify/vocab"; import { + type CryptoKeyLike, type DocumentLoader, FetchError, getDocumentLoader, UrlError, } from "@fedify/vocab-runtime"; +import { asCryptoKey } from "@fedify/vocab-runtime/internal"; import { getLogger } from "@logtape/logtape"; import { SpanKind, @@ -105,7 +107,7 @@ function classifyKeyJsonLdError( * @throws {TypeError} If the key is invalid or unsupported. */ export function validateCryptoKey( - key: CryptoKey, + key: CryptoKeyLike, type?: "public" | "private", ): void { if (type != null && key.type !== type) { @@ -181,9 +183,9 @@ export function generateCryptoKeyPair( * serialization and storage. * @throws {TypeError} If the key is invalid or unsupported. */ -export async function exportJwk(key: CryptoKey): Promise { +export async function exportJwk(key: CryptoKeyLike): Promise { validateCryptoKey(key); - const jwk = await crypto.subtle.exportKey("jwk", key); + const jwk = await crypto.subtle.exportKey("jwk", asCryptoKey(key)); if (jwk.crv === "Ed25519") jwk.alg = "Ed25519"; return jwk; } diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index eeed780d1..9b8afa27b 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -1,10 +1,12 @@ import { Activity, CryptographicKey, getTypeId, Object } from "@fedify/vocab"; import { + type CryptoKeyLike, type DocumentLoader, getDocumentLoader, preloadedContexts, type RemoteDocument, } from "@fedify/vocab-runtime"; +import { asCryptoKey } from "@fedify/vocab-runtime/internal"; import jsonld from "@fedify/vocab-runtime/jsonld"; import { getLogger } from "@logtape/logtape"; import { SpanStatusCode, trace, type TracerProvider } from "@opentelemetry/api"; @@ -838,7 +840,7 @@ export interface CreateSignatureOptions { */ export async function createSignature( jsonLd: unknown, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, { contextLoader, created }: CreateSignatureOptions = {}, ): Promise { @@ -867,7 +869,7 @@ export async function createSignature( const messageBytes = encoder.encode(message); const signature = await crypto.subtle.sign( "RSASSA-PKCS1-v1_5", - privateKey, + asCryptoKey(privateKey), messageBytes, ); return { @@ -906,7 +908,7 @@ export interface SignJsonLdOptions extends CreateSignatureOptions { */ export async function signJsonLd( jsonLd: unknown, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, options: SignJsonLdOptions, ): Promise<{ signature: Signature }> { diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index d28ce0e6d..2bdf32a5a 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -3,6 +3,7 @@ * * @module */ +export type { CryptoKeyLike, CryptoKeyPairLike } from "@fedify/vocab-runtime"; export { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index c77729a12..85864cbf0 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -5,7 +5,8 @@ import { Multikey, type Object, } from "@fedify/vocab"; -import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { CryptoKeyLike, DocumentLoader } from "@fedify/vocab-runtime"; +import { asCryptoKey } from "@fedify/vocab-runtime/internal"; import { getLogger } from "@logtape/logtape"; import { SpanStatusCode, trace, type TracerProvider } from "@opentelemetry/api"; import { encodeHex } from "byte-encodings/hex"; @@ -57,7 +58,7 @@ export interface CreateProofOptions { */ export async function createProof( object: Object, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, options: CreateProofOptions = {}, ): Promise { @@ -71,7 +72,7 @@ interface CreateProofResult { async function createProofInternal( object: Object, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, { contextLoader, context, created }: CreateProofOptions = {}, ): Promise { @@ -105,7 +106,11 @@ async function createProofInternal( const digest = new Uint8Array(proofDigest.byteLength + msgDigest.byteLength); digest.set(new Uint8Array(proofDigest), 0); digest.set(new Uint8Array(msgDigest), proofDigest.byteLength); - const sig = await crypto.subtle.sign("Ed25519", privateKey, digest); + const sig = await crypto.subtle.sign( + "Ed25519", + asCryptoKey(privateKey), + digest, + ); return { proof: new DataIntegrityProof({ cryptosuite: "eddsa-jcs-2022", @@ -166,7 +171,7 @@ export interface SignObjectOptions extends CreateProofOptions { */ export async function signObject( object: T, - privateKey: CryptoKey, + privateKey: CryptoKeyLike, keyId: URL, options: SignObjectOptions = {}, ): Promise { diff --git a/packages/fedify/src/utils/docloader.ts b/packages/fedify/src/utils/docloader.ts index 2aaf22aed..d91296654 100644 --- a/packages/fedify/src/utils/docloader.ts +++ b/packages/fedify/src/utils/docloader.ts @@ -1,5 +1,6 @@ import { createActivityPubRequest, + type CryptoKeyLike, type DocumentLoader, type DocumentLoaderFactoryOptions, type DocumentLoaderOptions, @@ -65,7 +66,7 @@ export interface GetAuthenticatedDocumentLoaderOptions * @since 0.4.0 */ export function getAuthenticatedDocumentLoader( - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, options: GetAuthenticatedDocumentLoaderOptions = {}, ): DocumentLoader { return createAuthenticatedDocumentLoader(identity, options); @@ -107,7 +108,7 @@ type LoadResult = * @throws {TypeError} If the key is invalid or unsupported. */ export function createAuthenticatedDocumentLoader( - identity: { keyId: URL; privateKey: CryptoKey }, + identity: { keyId: URL; privateKey: CryptoKeyLike }, { allowPrivateAddress, userAgent, specDeterminer, tracerProvider }: GetAuthenticatedDocumentLoaderOptions = {}, deps: AuthenticatedDocumentLoaderDependencies = {}, diff --git a/packages/vocab-runtime/src/crypto-key.ts b/packages/vocab-runtime/src/crypto-key.ts new file mode 100644 index 000000000..584235048 --- /dev/null +++ b/packages/vocab-runtime/src/crypto-key.ts @@ -0,0 +1,82 @@ +/** + * Structural representations of the Web Cryptography API key types, used for + * parameters that accept keys from any supported runtime. + * + * @module + */ + +/** + * A structural representation of a Web Cryptography API `CryptoKey`. + * + * Fedify accepts this shape instead of the ambient `CryptoKey` type wherever a + * key is supplied by the application. The ambient type depends on the + * consumer's `lib` setting, and `node:crypto`'s `CryptoKey` has been + * incompatible with the DOM and Web Worker ones since `@types/node` 24.7.0, + * which added the ML-KEM key usages to `webcrypto.KeyUsage`. Widening the + * input to this interface lets keys from Deno, Node.js, Bun, and Cloudflare + * Workers reach the same APIs. + * + * The keys are still native `CryptoKey` objects at run time; Fedify narrows + * them internally before calling `crypto.subtle` and validates them with + * {@link validateCryptoKey}. + * @since 2.0.36 + */ +export interface CryptoKeyLike { + /** + * The algorithm the key was generated for, along with any parameters. + */ + readonly algorithm: { readonly name: string }; + + /** + * Whether the key can be exported with `crypto.subtle.exportKey()`. + */ + readonly extractable: boolean; + + /** + * Whether the key is a private, public, or secret key. + * + * This is a plain `string` rather than a union of the known values because + * `@cloudflare/workers-types` declares it as `string`, and a narrower type + * would reject keys from the Workers runtime. + */ + readonly type: string; + + /** + * The operations the key may be used for. + * + * The element type is `string` for the same reason as {@link type}: Node.js + * and the DOM declare different sets of usages, and each set grows over time. + */ + readonly usages: readonly string[]; +} + +/** + * A structural representation of a Web Cryptography API `CryptoKeyPair`. + * + * See {@link CryptoKeyLike} for why Fedify takes this instead of the ambient + * `CryptoKeyPair` type on parameters. + * @since 2.0.36 + */ +export interface CryptoKeyPairLike { + /** + * The private key of the pair. + */ + readonly privateKey: CryptoKeyLike; + + /** + * The public key of the pair. + */ + readonly publicKey: CryptoKeyLike; +} + +/** + * Narrows a {@link CryptoKeyLike} to the ambient `CryptoKey` type, for passing + * it to `crypto.subtle` or to APIs that still declare the ambient type. + * + * This is internal to Fedify and is not exported from the package entry point. + * @param key The key to narrow. + * @returns The same key, typed as the ambient `CryptoKey`. + */ +export function asCryptoKey(key: CryptoKeyLike): CryptoKey { + return key as unknown as CryptoKey; +} diff --git a/packages/vocab-runtime/src/internal.ts b/packages/vocab-runtime/src/internal.ts index ac32e8355..aa6d9d186 100644 --- a/packages/vocab-runtime/src/internal.ts +++ b/packages/vocab-runtime/src/internal.ts @@ -11,3 +11,4 @@ export { type PublicFetchSession, withPublicFetch, } from "./public-fetch.ts"; +export { asCryptoKey } from "./crypto-key.ts"; diff --git a/packages/vocab-runtime/src/jwk.ts b/packages/vocab-runtime/src/jwk.ts index 3c001832f..652235f1e 100644 --- a/packages/vocab-runtime/src/jwk.ts +++ b/packages/vocab-runtime/src/jwk.ts @@ -1,5 +1,7 @@ +import { asCryptoKey, type CryptoKeyLike } from "./crypto-key.ts"; + export function validateCryptoKey( - key: CryptoKey, + key: CryptoKeyLike, type?: "public" | "private", ): void { if (type != null && key.type !== type) { @@ -30,9 +32,9 @@ export function validateCryptoKey( } } -export async function exportJwk(key: CryptoKey): Promise { +export async function exportJwk(key: CryptoKeyLike): Promise { validateCryptoKey(key); - const jwk = await crypto.subtle.exportKey("jwk", key); + const jwk = await crypto.subtle.exportKey("jwk", asCryptoKey(key)); if (jwk.crv === "Ed25519") jwk.alg = "Ed25519"; return jwk; } diff --git a/packages/vocab-runtime/src/key.ts b/packages/vocab-runtime/src/key.ts index 3e7f4e749..6d429e890 100644 --- a/packages/vocab-runtime/src/key.ts +++ b/packages/vocab-runtime/src/key.ts @@ -8,6 +8,7 @@ import { getMulticodecPrefix, removeMulticodecPrefix, } from "./internal/multicodec.ts"; +import { asCryptoKey, type CryptoKeyLike } from "./crypto-key.ts"; import { validateCryptoKey } from "./jwk.ts"; import { decodeMultibase, encodeMultibase } from "./multibase/mod.ts"; @@ -59,9 +60,9 @@ export async function importSpki(pem: string): Promise { * @throws {TypeError} If the key is invalid or unsupported. * @since 0.5.0 */ -export async function exportSpki(key: CryptoKey): Promise { +export async function exportSpki(key: CryptoKeyLike): Promise { validateCryptoKey(key); - const spki = await crypto.subtle.exportKey("spki", key); + const spki = await crypto.subtle.exportKey("spki", asCryptoKey(key)); let pem = encodeBase64(spki); pem = (pem.match(/.{1,64}/g) || []).join("\n"); return `-----BEGIN PUBLIC KEY-----\n${pem}\n-----END PUBLIC KEY-----\n`; @@ -146,18 +147,18 @@ export async function importMultibaseKey(key: string): Promise { * @throws {TypeError} If the key is invalid or unsupported. * @since 0.10.0 */ -export async function exportMultibaseKey(key: CryptoKey): Promise { +export async function exportMultibaseKey(key: CryptoKeyLike): Promise { let content: ArrayBuffer; let code: number; if (key.algorithm.name === "Ed25519") { - content = await crypto.subtle.exportKey("raw", key); + content = await crypto.subtle.exportKey("raw", asCryptoKey(key)); code = 0xed; // ed25519-pub } else if ( key.algorithm.name === "RSASSA-PKCS1-v1_5" && (key.algorithm as unknown as { hash: { name: string } }).hash.name === "SHA-256" ) { - const jwk = await crypto.subtle.exportKey("jwk", key); + const jwk = await crypto.subtle.exportKey("jwk", asCryptoKey(key)); const decodedN = decodeBase64Url(jwk.n!); const n = new Uint8Array(decodedN.length + 1); n.set(decodedN, 1); diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index 49f9fe93a..56164f5ed 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -5,6 +5,7 @@ * @module */ export { default as preloadedContexts } from "./contexts.ts"; +export type { CryptoKeyLike, CryptoKeyPairLike } from "./crypto-key.ts"; export { type AuthenticatedDocumentLoaderFactory, type DocumentLoader, diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap index 2498088cc..089404928 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap @@ -7,6 +7,7 @@ import { getLogger } from \\"@logtape/logtape\\"; import { type Span, SpanStatusCode, type TracerProvider, trace } from \\"@opentelemetry/api\\"; import { + type CryptoKeyLike, decodeMultibase, type DocumentLoader, encodeMultibase, @@ -19115,7 +19116,7 @@ export class CryptographicKey { values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -19181,7 +19182,7 @@ owner?: Application | Group | Organization | Person | Service | URL | null;publi values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { @@ -20036,7 +20037,7 @@ export class Multikey { values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -20102,7 +20103,7 @@ controller?: Application | Group | Organization | Person | Service | URL | null; values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap index fa6d1e4fb..21118a53f 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap @@ -5,6 +5,7 @@ import { getLogger } from \\"@logtape/logtape\\"; import { type Span, SpanStatusCode, type TracerProvider, trace } from \\"@opentelemetry/api\\"; import { + type CryptoKeyLike, decodeMultibase, type DocumentLoader, encodeMultibase, @@ -19113,7 +19114,7 @@ export class CryptographicKey { values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -19179,7 +19180,7 @@ owner?: Application | Group | Organization | Person | Service | URL | null;publi values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { @@ -20034,7 +20035,7 @@ export class Multikey { values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -20100,7 +20101,7 @@ controller?: Application | Group | Organization | Person | Service | URL | null; values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap index c549f83d2..ba31aa24f 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap @@ -7,6 +7,7 @@ import { getLogger } from "@logtape/logtape"; import { type Span, SpanStatusCode, type TracerProvider, trace } from "@opentelemetry/api"; import { + type CryptoKeyLike, decodeMultibase, type DocumentLoader, encodeMultibase, @@ -19115,7 +19116,7 @@ export class CryptographicKey { values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -19181,7 +19182,7 @@ owner?: Application | Group | Organization | Person | Service | URL | null;publi values: { id?: URL | null; -owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +owner?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { @@ -20036,7 +20037,7 @@ export class Multikey { values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} , options: { documentLoader?: DocumentLoader, @@ -20102,7 +20103,7 @@ controller?: Application | Group | Organization | Person | Service | URL | null; values: { id?: URL | null; -controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKey | null;} +controller?: Application | Group | Organization | Person | Service | URL | null;publicKey?: CryptoKeyLike | null;} = {}, options: { diff --git a/packages/vocab-tools/src/class.ts b/packages/vocab-tools/src/class.ts index 4d989ddca..832a3fe56 100644 --- a/packages/vocab-tools/src/class.ts +++ b/packages/vocab-tools/src/class.ts @@ -143,6 +143,7 @@ export async function* generateClasses( yield `import { type Span, SpanStatusCode, type TracerProvider, trace } from "@opentelemetry/api";\n`; yield `import { + type CryptoKeyLike, decodeMultibase, type DocumentLoader, encodeMultibase, diff --git a/packages/vocab-tools/src/constructor.ts b/packages/vocab-tools/src/constructor.ts index ff3180f03..22b794b7a 100644 --- a/packages/vocab-tools/src/constructor.ts +++ b/packages/vocab-tools/src/constructor.ts @@ -8,6 +8,13 @@ import { getTypeNames, } from "./type.ts"; +/** + * Generates the TypeScript type of a property in the `values` object that the + * constructor and `clone()` accept. + * @param property The property to generate the type for. + * @param types The vocabulary types in scope. + * @returns The generated type expression. + */ function generateParameterType( property: PropertySchema, types: Record, @@ -18,12 +25,14 @@ function generateParameterType( if (hasSingularAccessor(property)) { if (scalar) { code.push( - `${property.singularName}?: ${getTypeNames(range, types)} | null;`, + `${property.singularName}?: ${ + getTypeNames(range, types, false, true) + } | null;`, ); } else { code.push( `${property.singularName}?: ${ - getTypeNames(range, types) + getTypeNames(range, types, false, true) } | URL | null;`, ); } @@ -31,11 +40,15 @@ function generateParameterType( if (isNonFunctionalProperty(property)) { if (scalar) { code.push( - `${property.pluralName}?: (${getTypeNames(range, types, true)})[];`, + `${property.pluralName}?: (${ + getTypeNames(range, types, true, true) + })[];`, ); } else { code.push( - `${property.pluralName}?: (${getTypeNames(range, types)} | URL)[];`, + `${property.pluralName}?: (${ + getTypeNames(range, types, false, true) + } | URL)[];`, ); } } diff --git a/packages/vocab-tools/src/type.ts b/packages/vocab-tools/src/type.ts index fb1efda8e..5988341ea 100644 --- a/packages/vocab-tools/src/type.ts +++ b/packages/vocab-tools/src/type.ts @@ -17,6 +17,11 @@ const ACTIVITY_TYPE_URI = "https://www.w3.org/ns/activitystreams#Activity"; interface ScalarType { name: string; + /** + * The type name to use for values accepted by constructors and `clone()`, + * when it is wider than {@link name}. Defaults to {@link name}. + */ + inputName?: string; typeGuard(variable: string): string; encoder(variable: string): string; compactEncoder?: (variable: string) => string; @@ -333,6 +338,7 @@ const scalarTypes: Record = { }, "fedify:publicKey": { name: "CryptoKey", + inputName: "CryptoKeyLike", typeGuard(v) { return ` // @ts-ignore: CryptoKey exists in the global scope. @@ -355,6 +361,7 @@ const scalarTypes: Record = { }, "fedify:multibaseKey": { name: "CryptoKey", + inputName: "CryptoKeyLike", typeGuard(v) { return ` // @ts-ignore: CryptoKey exists in the global scope. @@ -431,23 +438,50 @@ const scalarTypes: Record = { }, }; +/** + * Gets the TypeScript type name for a type URI. + * @param typeUri The type URI to resolve. + * @param types The vocabulary types in scope. + * @param forInput Whether to return the type accepted as input by + * constructors and `clone()`, which can be wider than the type + * those APIs return. + * @returns The TypeScript type name. + */ export function getTypeName( typeUri: string, types: Record, + forInput = false, ): string { if (typeUri in types) return types[typeUri].name; - if (typeUri in scalarTypes) return scalarTypes[typeUri].name; + if (typeUri in scalarTypes) { + const scalar = scalarTypes[typeUri]; + return forInput ? scalar.inputName ?? scalar.name : scalar.name; + } throw new Error(`Unknown type: ${typeUri}`); } +/** + * Gets the TypeScript type names for a list of type URIs. + * @param typeUris The type URIs to resolve. + * @param types The vocabulary types in scope. + * @param parentheses Whether to wrap a union in parentheses. + * @param forInput Whether to return the types accepted as input by + * constructors and `clone()`. + * @returns The TypeScript type expression. + */ export function getTypeNames( typeUris: string[], types: Record, parentheses = false, + forInput = false, ): string { if (typeUris.length < 1) return "never"; - else if (typeUris.length === 1) return getTypeName(typeUris[0], types); - let typeNames = typeUris.map((typeUri) => getTypeName(typeUri, types)); + else if (typeUris.length === 1) { + return getTypeName(typeUris[0], types, forInput); + } + let typeNames = typeUris.map((typeUri) => + getTypeName(typeUri, types, forInput) + ); typeNames = typeNames.filter((t, i) => typeNames.indexOf(t) === i); const t = typeNames.join(" | "); return parentheses && typeNames.length > 1 ? `(${t})` : t; diff --git a/packages/vocab/scripts/codegen.ts b/packages/vocab/scripts/codegen.ts index 11aa095a5..1c8175ef9 100644 --- a/packages/vocab/scripts/codegen.ts +++ b/packages/vocab/scripts/codegen.ts @@ -7,35 +7,82 @@ const LOCK_RETRY_MS = 100; const LOCK_TIMEOUT_MS = 60 * 1000; // 1 minute /** - * Get the latest mtime from all YAML files in the schema directory. + * Get the latest mtime of the files under the given directory that match the + * predicate. + * @param dir The directory to search. + * @param matches The predicate to select files by their path relative to + * `dir`. */ -async function getLatestSourceMtime(schemaDir: Path): Promise { +async function getLatestMtime( + dir: Path, + matches: (relativePath: string) => boolean, +): Promise { let latestMtime = 0; - for await (const entry of schemaDir.readDir()) { - if (!entry.isFile) continue; - if (!entry.name.match(/\.ya?ml$/i)) continue; - if (entry.name === "schema.yaml") continue; - const fileStat = await schemaDir.join(entry.name).stat(); - if (fileStat?.mtime && fileStat.mtime.getTime() > latestMtime) { - latestMtime = fileStat.mtime.getTime(); + async function visit(current: Path, prefix: string): Promise { + for await (const entry of Deno.readDir(current.toString())) { + const relativePath = prefix === "" + ? entry.name + : `${prefix}/${entry.name}`; + if (entry.isDirectory) { + await visit(current.join(entry.name), relativePath); + } else if (entry.isFile && matches(relativePath)) { + const fileStat = await current.join(entry.name).stat(); + if (fileStat?.mtime && fileStat.mtime.getTime() > latestMtime) { + latestMtime = fileStat.mtime.getTime(); + } + } } } + await visit(dir, ""); return latestMtime; } /** - * Check if the generated file is up to date compared to source files. + * Check whether a path under the schema directory holds a vocabulary schema. + */ +function isSchemaFile(relativePath: string): boolean { + return /\.ya?ml$/i.test(relativePath) && relativePath !== "schema.yaml"; +} + +/** + * Check whether a path under the generator directory can change the generated + * output. + */ +function isGeneratorSource(relativePath: string): boolean { + if (!relativePath.endsWith(".ts")) return false; + return !relativePath.endsWith(".test.ts") && + !relativePath.endsWith(".bench.ts"); +} + +/** + * Check if the generated file is up to date compared to the vocabulary schemas + * and the code generator. + * @param schemaDir The directory holding the vocabulary schemas. + * @param generatedPath The generated file. + * @param generatorDir The directory holding the code generator sources. + * @param generatorMetadata The generator's *deno.json*, whose version is + * embedded in the generated code. */ async function isUpToDate( schemaDir: Path, generatedPath: Path, + generatorDir: Path, + generatorMetadata: Path, ): Promise { try { - const [sourceMtime, generatedStat] = await Promise.all([ - getLatestSourceMtime(schemaDir), - generatedPath.stat(), - ]); + const [schemaMtime, generatorMtime, metadataStat, generatedStat] = + await Promise.all([ + getLatestMtime(schemaDir, isSchemaFile), + getLatestMtime(generatorDir, isGeneratorSource), + generatorMetadata.stat(), + generatedPath.stat(), + ]); if (!generatedStat?.mtime) return false; + const sourceMtime = Math.max( + schemaMtime, + generatorMtime, + metadataStat?.mtime?.getTime() ?? 0, + ); return generatedStat.mtime.getTime() >= sourceMtime; } catch { // If generated file doesn't exist, it's not up to date @@ -111,6 +158,11 @@ async function codegen() { const scriptsDir = $.path(import.meta.dirname!); const packageDir = scriptsDir.parent()!; const schemaDir = packageDir.join("src"); + const generatorDir = packageDir.parent()!.join("vocab-tools", "src"); + const generatorMetadata = packageDir.parent()!.join( + "vocab-tools", + "deno.json", + ); const realPath = schemaDir.join("vocab.ts"); const lockPath = packageDir.join(".vocab-codegen.lock"); @@ -118,7 +170,9 @@ async function codegen() { const lock = await acquireLock(lockPath); try { // Check if regeneration is needed (after acquiring lock) - if (await isUpToDate(schemaDir, realPath)) { + if ( + await isUpToDate(schemaDir, realPath, generatorDir, generatorMetadata) + ) { $.log("vocab.ts is up to date, skipping codegen"); return; }