@oxy.so/core 7.25.0 → 7.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -118,9 +118,26 @@ class IdentityApi {
118
118
  * Resolve the W3C DID document for any user. The API derives it on demand
119
119
  * from the account's `authMethods` + `publicKey` — there is no stored
120
120
  * document. Public (no auth required); short-TTL cached.
121
+ *
122
+ * `{ fresh: true }` reads the document as it is NOW: it skips (and drops)
123
+ * this client's cached copy, is not joined to an in-flight read, and sends
124
+ * `Cache-Control: no-cache` so no HTTP cache on the way answers for the
125
+ * origin. Use it whenever the answer decides where money goes: `#key-1` is
126
+ * the account's Peable receive key and changes on a rotation or an
127
+ * organization key replacement, and a cached copy would derive an address
128
+ * from the previous key for up to the cache TTL.
121
129
  */
122
- async resolveDid(userId) {
123
- return this.ctx.request('GET', `/u/${encodeURIComponent(userId)}/did.json`, undefined, {
130
+ async resolveDid(userId, options = {}) {
131
+ const path = `/u/${encodeURIComponent(userId)}/did.json`;
132
+ if (options.fresh) {
133
+ this.ctx.http.invalidateCache({ keys: [`GET:${path}`] });
134
+ return this.ctx.request('GET', path, undefined, {
135
+ cache: false,
136
+ deduplicate: false,
137
+ headers: { 'Cache-Control': 'no-cache' },
138
+ });
139
+ }
140
+ return this.ctx.request('GET', path, undefined, {
124
141
  cache: true,
125
142
  cacheTTL: SHORT_TTL,
126
143
  });
@@ -65,6 +65,12 @@ function deriveScopedSeedFromKey(privateKeyHex, info) {
65
65
  * `HMAC-SHA256(SOCIAL_RECEIVE_CHAIN_CODE_KEY, compressed identity public key)`.
66
66
  * Identical to `@fairco.in/core`'s `deriveSocialReceiveSpendingKey`.
67
67
  *
68
+ * INTERNAL to `@oxy.so/core` (not exported from `@oxy.so/core/crypto`): the
69
+ * derivation is non-hardened and the chain code is a public function of the
70
+ * identity public key, so `identity = child - I_L mod n` — whoever holds one
71
+ * child private key and the public key holds the identity key. Callers get
72
+ * signatures ({@link signSocialReceiveDigest}), never the child key.
73
+ *
68
74
  * @returns the child private key and its compressed public key, lowercase hex.
69
75
  */
70
76
  function deriveSocialReceiveKey(privateKeyHex, index) {
@@ -12,7 +12,7 @@
12
12
  * common chunk, which is the cost the lazy loading exists to avoid.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = exports.deriveSocialReceiveKey = exports.deriveScopedSeedFromKey = exports.MAX_SOCIAL_RECEIVE_INDEX = exports.SOCIAL_RECEIVE_CHAIN_CODE_KEY = exports.SCOPED_SEED_KDF_SALT = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = exports.isHealthyOrganizationKeyPair = exports.isOrganizationAccountId = exports.syncOrganizationKeys = exports.deleteOrganizationKey = exports.listOrganizationKeys = exports.getOrganizationRecoveryPhrase = exports.getOrganizationPrivateKey = exports.getOrganizationPublicKey = exports.storeOrganizationKey = exports.organizationKeyFromPhrase = exports.generateOrganizationKey = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = void 0;
15
+ exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = exports.deriveScopedSeedFromKey = exports.MAX_SOCIAL_RECEIVE_INDEX = exports.SOCIAL_RECEIVE_CHAIN_CODE_KEY = exports.SCOPED_SEED_KDF_SALT = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = exports.isHealthyOrganizationKeyPair = exports.isOrganizationAccountId = exports.syncOrganizationKeys = exports.deleteOrganizationKey = exports.listOrganizationKeys = exports.getOrganizationRecoveryPhrase = exports.getOrganizationPrivateKey = exports.getOrganizationPublicKey = exports.storeOrganizationKey = exports.organizationKeyFromPhrase = exports.generateOrganizationKey = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = void 0;
16
16
  // MUST stay the first import: the shim has to exist before any module below
17
17
  // evaluates or draws randomness (see `./polyfill`).
18
18
  require("./polyfill");
@@ -53,13 +53,16 @@ Object.defineProperty(exports, "AEAD_NONCE_LENGTH", { enumerable: true, get: fun
53
53
  var ecdh_1 = require("./ecdh");
54
54
  Object.defineProperty(exports, "deriveSharedSecret", { enumerable: true, get: function () { return ecdh_1.deriveSharedSecret; } });
55
55
  // What the identity key computes without leaving its holder: the same functions
56
- // Commons' identity host implements natively on Android.
56
+ // Commons' identity host implements natively on Android. The social-receive
57
+ // CHILD private key (`deriveSocialReceiveKey`) is deliberately not here: the
58
+ // derivation is non-hardened under a chain code anyone can compute from the
59
+ // public key, so one leaked child private key reveals the identity key. Only
60
+ // signatures (`signSocialReceiveDigest`) leave this module.
57
61
  var identityDerivations_1 = require("./identityDerivations");
58
62
  Object.defineProperty(exports, "SCOPED_SEED_KDF_SALT", { enumerable: true, get: function () { return identityDerivations_1.SCOPED_SEED_KDF_SALT; } });
59
63
  Object.defineProperty(exports, "SOCIAL_RECEIVE_CHAIN_CODE_KEY", { enumerable: true, get: function () { return identityDerivations_1.SOCIAL_RECEIVE_CHAIN_CODE_KEY; } });
60
64
  Object.defineProperty(exports, "MAX_SOCIAL_RECEIVE_INDEX", { enumerable: true, get: function () { return identityDerivations_1.MAX_SOCIAL_RECEIVE_INDEX; } });
61
65
  Object.defineProperty(exports, "deriveScopedSeedFromKey", { enumerable: true, get: function () { return identityDerivations_1.deriveScopedSeedFromKey; } });
62
- Object.defineProperty(exports, "deriveSocialReceiveKey", { enumerable: true, get: function () { return identityDerivations_1.deriveSocialReceiveKey; } });
63
66
  Object.defineProperty(exports, "signSocialReceiveDigest", { enumerable: true, get: function () { return identityDerivations_1.signSocialReceiveDigest; } });
64
67
  Object.defineProperty(exports, "authChallengeDigest", { enumerable: true, get: function () { return identityDerivations_1.authChallengeDigest; } });
65
68
  // Identity proofs — the one signed format for operations on a personal root
package/dist/cjs/index.js CHANGED
@@ -21,10 +21,10 @@
21
21
  * If a symbol does not appear here, it is NOT part of the public API.
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.sameHolderIdentity = exports.retryAfterSecondsOf = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
- exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = void 0;
26
- exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = exports.isLocaleLoaded = exports.loadLocale = void 0;
27
- exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = void 0;
24
+ exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.supportProviderOf = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.sameHolderIdentity = exports.retryAfterSecondsOf = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
+ exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = void 0;
26
+ exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = exports.isLocaleLoaded = exports.loadLocale = exports.translate = void 0;
27
+ exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = void 0;
28
28
  // The crypto shim MUST stay the first import. The root itself ships no crypto
29
29
  // library (`__tests__/rootEntryGraph.test.ts`), but `@oxy.so/protocol`'s root
30
30
  // entry (reached by the OAuth/PKCE and device helpers) does, and Metro does not
@@ -97,6 +97,7 @@ Object.defineProperty(exports, "getCanonicalUserHandle", { enumerable: true, get
97
97
  Object.defineProperty(exports, "getNormalizedUserHandle", { enumerable: true, get: function () { return userHandle_1.getNormalizedUserHandle; } });
98
98
  var profileLinks_1 = require("./utils/profileLinks");
99
99
  Object.defineProperty(exports, "normalizeProfileLinks", { enumerable: true, get: function () { return profileLinks_1.normalizeProfileLinks; } });
100
+ Object.defineProperty(exports, "supportProviderOf", { enumerable: true, get: function () { return profileLinks_1.supportProviderOf; } });
100
101
  // ---------------------------------------------------------------------------
101
102
  // Auth helpers (token refresh, error normalisation, retry policies)
102
103
  // ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeProfileLinks = normalizeProfileLinks;
4
+ exports.supportProviderOf = supportProviderOf;
4
5
  function cleanUrl(value) {
5
6
  if (typeof value !== 'string')
6
7
  return null;
@@ -37,12 +38,14 @@ function normalizeProfileLinks(linksMetadata, links) {
37
38
  const title = typeof entry?.title === 'string' ? entry.title : undefined;
38
39
  const description = typeof entry?.description === 'string' ? entry.description : undefined;
39
40
  const image = typeof entry?.image === 'string' ? entry.image : undefined;
41
+ const faviconUrl = typeof entry?.faviconUrl === 'string' ? entry.faviconUrl : undefined;
40
42
  result.push({
41
43
  id,
42
44
  url,
43
45
  ...(title !== undefined ? { title } : {}),
44
46
  ...(description !== undefined ? { description } : {}),
45
47
  ...(image !== undefined ? { image } : {}),
48
+ ...(faviconUrl !== undefined ? { faviconUrl } : {}),
46
49
  });
47
50
  });
48
51
  return result;
@@ -59,3 +62,34 @@ function normalizeProfileLinks(linksMetadata, links) {
59
62
  }
60
63
  return [];
61
64
  }
65
+ const SUPPORT_PROVIDERS = [
66
+ { provider: { id: 'patreon', name: 'Patreon' }, hosts: ['patreon.com'] },
67
+ { provider: { id: 'kofi', name: 'Ko-fi' }, hosts: ['ko-fi.com'] },
68
+ { provider: { id: 'buymeacoffee', name: 'Buy Me a Coffee' }, hosts: ['buymeacoffee.com'] },
69
+ { provider: { id: 'github-sponsors', name: 'GitHub Sponsors' }, hosts: ['github.com'] },
70
+ { provider: { id: 'liberapay', name: 'Liberapay' }, hosts: ['liberapay.com'] },
71
+ ];
72
+ /**
73
+ * The support platform a link points at, or `null`. GitHub Sponsors is the one
74
+ * host shared with ordinary GitHub pages, so only its `/sponsors/` path counts.
75
+ */
76
+ function supportProviderOf(url) {
77
+ let parsed;
78
+ try {
79
+ parsed = new URL(url);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
85
+ for (const { provider, hosts } of SUPPORT_PROVIDERS) {
86
+ if (!hosts.some((known) => host === known || host.endsWith(`.${known}`)))
87
+ continue;
88
+ if (provider.id === 'github-sponsors' &&
89
+ !parsed.pathname.toLowerCase().startsWith('/sponsors/')) {
90
+ continue;
91
+ }
92
+ return provider;
93
+ }
94
+ return null;
95
+ }
@@ -80,9 +80,26 @@ export class IdentityApi {
80
80
  * Resolve the W3C DID document for any user. The API derives it on demand
81
81
  * from the account's `authMethods` + `publicKey` — there is no stored
82
82
  * document. Public (no auth required); short-TTL cached.
83
+ *
84
+ * `{ fresh: true }` reads the document as it is NOW: it skips (and drops)
85
+ * this client's cached copy, is not joined to an in-flight read, and sends
86
+ * `Cache-Control: no-cache` so no HTTP cache on the way answers for the
87
+ * origin. Use it whenever the answer decides where money goes: `#key-1` is
88
+ * the account's Peable receive key and changes on a rotation or an
89
+ * organization key replacement, and a cached copy would derive an address
90
+ * from the previous key for up to the cache TTL.
83
91
  */
84
- async resolveDid(userId) {
85
- return this.ctx.request('GET', `/u/${encodeURIComponent(userId)}/did.json`, undefined, {
92
+ async resolveDid(userId, options = {}) {
93
+ const path = `/u/${encodeURIComponent(userId)}/did.json`;
94
+ if (options.fresh) {
95
+ this.ctx.http.invalidateCache({ keys: [`GET:${path}`] });
96
+ return this.ctx.request('GET', path, undefined, {
97
+ cache: false,
98
+ deduplicate: false,
99
+ headers: { 'Cache-Control': 'no-cache' },
100
+ });
101
+ }
102
+ return this.ctx.request('GET', path, undefined, {
86
103
  cache: true,
87
104
  cacheTTL: SHORT_TTL,
88
105
  });
@@ -58,6 +58,12 @@ export function deriveScopedSeedFromKey(privateKeyHex, info) {
58
58
  * `HMAC-SHA256(SOCIAL_RECEIVE_CHAIN_CODE_KEY, compressed identity public key)`.
59
59
  * Identical to `@fairco.in/core`'s `deriveSocialReceiveSpendingKey`.
60
60
  *
61
+ * INTERNAL to `@oxy.so/core` (not exported from `@oxy.so/core/crypto`): the
62
+ * derivation is non-hardened and the chain code is a public function of the
63
+ * identity public key, so `identity = child - I_L mod n` — whoever holds one
64
+ * child private key and the public key holds the identity key. Callers get
65
+ * signatures ({@link signSocialReceiveDigest}), never the child key.
66
+ *
61
67
  * @returns the child private key and its compressed public key, lowercase hex.
62
68
  */
63
69
  export function deriveSocialReceiveKey(privateKeyHex, index) {
@@ -24,8 +24,12 @@ export { hkdfSha256 } from './kdf.js';
24
24
  export { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH } from './aead.js';
25
25
  export { deriveSharedSecret } from './ecdh.js';
26
26
  // What the identity key computes without leaving its holder: the same functions
27
- // Commons' identity host implements natively on Android.
28
- export { SCOPED_SEED_KDF_SALT, SOCIAL_RECEIVE_CHAIN_CODE_KEY, MAX_SOCIAL_RECEIVE_INDEX, deriveScopedSeedFromKey, deriveSocialReceiveKey, signSocialReceiveDigest, authChallengeDigest, } from './identityDerivations.js';
27
+ // Commons' identity host implements natively on Android. The social-receive
28
+ // CHILD private key (`deriveSocialReceiveKey`) is deliberately not here: the
29
+ // derivation is non-hardened under a chain code anyone can compute from the
30
+ // public key, so one leaked child private key reveals the identity key. Only
31
+ // signatures (`signSocialReceiveDigest`) leave this module.
32
+ export { SCOPED_SEED_KDF_SALT, SOCIAL_RECEIVE_CHAIN_CODE_KEY, MAX_SOCIAL_RECEIVE_INDEX, deriveScopedSeedFromKey, signSocialReceiveDigest, authChallengeDigest, } from './identityDerivations.js';
29
33
  // Identity proofs — the one signed format for operations on a personal root
30
34
  // (docs/adr/0024-one-oxy-account-root-holders.md D7)
31
35
  export { digestIdentityPayload, signIdentityProof } from './identityProof.js';
package/dist/esm/index.js CHANGED
@@ -53,7 +53,7 @@ export { selectCommonsDelivery, pushTargetsFromDelivery, commonsDeliveryPlatform
53
53
  // ---------------------------------------------------------------------------
54
54
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity.js';
55
55
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle.js';
56
- export { normalizeProfileLinks } from './utils/profileLinks.js';
56
+ export { normalizeProfileLinks, supportProviderOf } from './utils/profileLinks.js';
57
57
  // ---------------------------------------------------------------------------
58
58
  // Auth helpers (token refresh, error normalisation, retry policies)
59
59
  // ---------------------------------------------------------------------------
@@ -34,12 +34,14 @@ export function normalizeProfileLinks(linksMetadata, links) {
34
34
  const title = typeof entry?.title === 'string' ? entry.title : undefined;
35
35
  const description = typeof entry?.description === 'string' ? entry.description : undefined;
36
36
  const image = typeof entry?.image === 'string' ? entry.image : undefined;
37
+ const faviconUrl = typeof entry?.faviconUrl === 'string' ? entry.faviconUrl : undefined;
37
38
  result.push({
38
39
  id,
39
40
  url,
40
41
  ...(title !== undefined ? { title } : {}),
41
42
  ...(description !== undefined ? { description } : {}),
42
43
  ...(image !== undefined ? { image } : {}),
44
+ ...(faviconUrl !== undefined ? { faviconUrl } : {}),
43
45
  });
44
46
  });
45
47
  return result;
@@ -56,3 +58,34 @@ export function normalizeProfileLinks(linksMetadata, links) {
56
58
  }
57
59
  return [];
58
60
  }
61
+ const SUPPORT_PROVIDERS = [
62
+ { provider: { id: 'patreon', name: 'Patreon' }, hosts: ['patreon.com'] },
63
+ { provider: { id: 'kofi', name: 'Ko-fi' }, hosts: ['ko-fi.com'] },
64
+ { provider: { id: 'buymeacoffee', name: 'Buy Me a Coffee' }, hosts: ['buymeacoffee.com'] },
65
+ { provider: { id: 'github-sponsors', name: 'GitHub Sponsors' }, hosts: ['github.com'] },
66
+ { provider: { id: 'liberapay', name: 'Liberapay' }, hosts: ['liberapay.com'] },
67
+ ];
68
+ /**
69
+ * The support platform a link points at, or `null`. GitHub Sponsors is the one
70
+ * host shared with ordinary GitHub pages, so only its `/sponsors/` path counts.
71
+ */
72
+ export function supportProviderOf(url) {
73
+ let parsed;
74
+ try {
75
+ parsed = new URL(url);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
81
+ for (const { provider, hosts } of SUPPORT_PROVIDERS) {
82
+ if (!hosts.some((known) => host === known || host.endsWith(`.${known}`)))
83
+ continue;
84
+ if (provider.id === 'github-sponsors' &&
85
+ !parsed.pathname.toLowerCase().startsWith('/sponsors/')) {
86
+ continue;
87
+ }
88
+ return provider;
89
+ }
90
+ return null;
91
+ }
@@ -10,6 +10,15 @@ export interface VerifyDomainResult {
10
10
  export interface RemoveDomainResult {
11
11
  success: boolean;
12
12
  }
13
+ /** Options for {@link IdentityApi.resolveDid}. */
14
+ export interface ResolveDidOptions {
15
+ /**
16
+ * Read the document from the origin now, never from a cache. Required when
17
+ * `#key-1` decides where money goes (deriving a Peable receive address): the
18
+ * key changes on a rotation or an organization key replacement.
19
+ */
20
+ fresh?: boolean;
21
+ }
13
22
  /** How the caller proves control of the CURRENT key during a key rotation. */
14
23
  export type RotateKeyProof = 'device' | 'phrase';
15
24
  /** Options for {@link IdentityApi.rotateKey}. */
@@ -93,8 +102,16 @@ export declare class IdentityApi {
93
102
  * Resolve the W3C DID document for any user. The API derives it on demand
94
103
  * from the account's `authMethods` + `publicKey` — there is no stored
95
104
  * document. Public (no auth required); short-TTL cached.
105
+ *
106
+ * `{ fresh: true }` reads the document as it is NOW: it skips (and drops)
107
+ * this client's cached copy, is not joined to an in-flight read, and sends
108
+ * `Cache-Control: no-cache` so no HTTP cache on the way answers for the
109
+ * origin. Use it whenever the answer decides where money goes: `#key-1` is
110
+ * the account's Peable receive key and changes on a rotation or an
111
+ * organization key replacement, and a cached copy would derive an address
112
+ * from the previous key for up to the cache TTL.
96
113
  */
97
- resolveDid(userId: string): Promise<DidDocument>;
114
+ resolveDid(userId: string, options?: ResolveDidOptions): Promise<DidDocument>;
98
115
  /**
99
116
  * The signed-in user's linked authentication methods plus their DID. Each
100
117
  * `identity` method carries a `verificationMethodId` linking it to its DID
@@ -37,6 +37,12 @@ export declare function deriveScopedSeedFromKey(privateKeyHex: string, info: str
37
37
  * `HMAC-SHA256(SOCIAL_RECEIVE_CHAIN_CODE_KEY, compressed identity public key)`.
38
38
  * Identical to `@fairco.in/core`'s `deriveSocialReceiveSpendingKey`.
39
39
  *
40
+ * INTERNAL to `@oxy.so/core` (not exported from `@oxy.so/core/crypto`): the
41
+ * derivation is non-hardened and the chain code is a public function of the
42
+ * identity public key, so `identity = child - I_L mod n` — whoever holds one
43
+ * child private key and the public key holds the identity key. Callers get
44
+ * signatures ({@link signSocialReceiveDigest}), never the child key.
45
+ *
40
46
  * @returns the child private key and its compressed public key, lowercase hex.
41
47
  */
42
48
  export declare function deriveSocialReceiveKey(privateKeyHex: string, index: number): {
@@ -27,7 +27,7 @@ export { hkdfSha256 } from './kdf';
27
27
  export { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH } from './aead';
28
28
  export type { AeadResult } from './aead';
29
29
  export { deriveSharedSecret } from './ecdh';
30
- export { SCOPED_SEED_KDF_SALT, SOCIAL_RECEIVE_CHAIN_CODE_KEY, MAX_SOCIAL_RECEIVE_INDEX, deriveScopedSeedFromKey, deriveSocialReceiveKey, signSocialReceiveDigest, authChallengeDigest, } from './identityDerivations';
30
+ export { SCOPED_SEED_KDF_SALT, SOCIAL_RECEIVE_CHAIN_CODE_KEY, MAX_SOCIAL_RECEIVE_INDEX, deriveScopedSeedFromKey, signSocialReceiveDigest, authChallengeDigest, } from './identityDerivations';
31
31
  export { digestIdentityPayload, signIdentityProof } from './identityProof';
32
32
  export { deriveIdentityLinkCode } from './identityLink';
33
33
  export { solveRegistrationPow } from './registrationPow';
@@ -41,7 +41,7 @@ export { DirectUploadInterruptedError } from './api/directUpload';
41
41
  export type { DirectUploadProgress } from './api/directUpload';
42
42
  export type { AssetsApi, AssetRecord, UploadedAsset, AssetLinkState, AssetUploadOptions, AssetUploadStageProgress, AssetLinkTarget, AssetDeleteResult, CompletedAssetUpload, LargeUploadOptions, LargeUploadProgress, LargeUploadResult, } from './api/assets';
43
43
  export { buildUserDid } from './api/identity';
44
- export type { IdentityApi, VerifyDomainResult, RemoveDomainResult, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './api/identity';
44
+ export type { IdentityApi, VerifyDomainResult, RemoveDomainResult, ResolveDidOptions, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './api/identity';
45
45
  export { ACCOUNT_CATEGORY_IDS, MAX_ACCOUNT_CATEGORIES, SELECTABLE_ACCOUNT_CATEGORY_IDS, isSelectableAccountCategoryId, kindAcceptsAccountCategories, } from './api/accounts';
46
46
  export type { AccountsApi, AccountKind, AccountCategoryId, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, AccountSuccessResult, SwitchAccountResult, } from './api/accounts';
47
47
  export type { AppsApi, AppAssertionsApi, Application, ApplicationCallerMembership, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, RotateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, PublicApplication, ConnectedApp, ConnectedMcpClient, ApplicationDeleteResult, ApplicationFilesWebhook, } from './api/apps';
@@ -70,8 +70,8 @@ export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRout
70
70
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
71
71
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
72
72
  export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHandle';
73
- export { normalizeProfileLinks } from './utils/profileLinks';
74
- export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
73
+ export { normalizeProfileLinks, supportProviderOf } from './utils/profileLinks';
74
+ export type { ProfileLink, ProfileLinkMetadata, SupportProvider } from './utils/profileLinks';
75
75
  export { SessionSyncRequiredError, AuthenticationFailedError, ensureValidToken, isAuthenticationError, withAuthErrorHandling, authenticatedApiCall, } from './utils/authHelpers';
76
76
  export type { HandleApiErrorOptions } from './utils/authHelpers';
77
77
  export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual, } from './utils/sessionUtils';
@@ -13,6 +13,8 @@ export interface ProfileLink {
13
13
  url: string;
14
14
  description?: string;
15
15
  image?: string;
16
+ /** Clarity's favicon for the link's host, when Clarity has one. */
17
+ faviconUrl?: string;
16
18
  }
17
19
  /** Source shape of a single `User.linksMetadata` entry. */
18
20
  export interface ProfileLinkMetadata {
@@ -21,6 +23,7 @@ export interface ProfileLinkMetadata {
21
23
  description?: string;
22
24
  image?: string;
23
25
  id?: string;
26
+ faviconUrl?: string;
24
27
  }
25
28
  /**
26
29
  * Normalizes a user's profile links into a clean display shape.
@@ -42,3 +45,13 @@ export interface ProfileLinkMetadata {
42
45
  * as `https://`; prefixing is a display concern left to the caller.
43
46
  */
44
47
  export declare function normalizeProfileLinks(linksMetadata?: ProfileLinkMetadata[], links?: string[]): ProfileLink[];
48
+ /** A platform a profile can point supporters to, recognised by the link's host. */
49
+ export interface SupportProvider {
50
+ id: 'patreon' | 'kofi' | 'buymeacoffee' | 'github-sponsors' | 'liberapay';
51
+ name: string;
52
+ }
53
+ /**
54
+ * The support platform a link points at, or `null`. GitHub Sponsors is the one
55
+ * host shared with ordinary GitHub pages, so only its `/sponsors/` path counts.
56
+ */
57
+ export declare function supportProviderOf(url: string): SupportProvider | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.25.0",
3
+ "version": "7.27.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -68,6 +68,16 @@ export interface RemoveDomainResult {
68
68
  success: boolean;
69
69
  }
70
70
 
71
+ /** Options for {@link IdentityApi.resolveDid}. */
72
+ export interface ResolveDidOptions {
73
+ /**
74
+ * Read the document from the origin now, never from a cache. Required when
75
+ * `#key-1` decides where money goes (deriving a Peable receive address): the
76
+ * key changes on a rotation or an organization key replacement.
77
+ */
78
+ fresh?: boolean;
79
+ }
80
+
71
81
  /** How the caller proves control of the CURRENT key during a key rotation. */
72
82
  export type RotateKeyProof = 'device' | 'phrase';
73
83
 
@@ -191,17 +201,29 @@ export class IdentityApi {
191
201
  * Resolve the W3C DID document for any user. The API derives it on demand
192
202
  * from the account's `authMethods` + `publicKey` — there is no stored
193
203
  * document. Public (no auth required); short-TTL cached.
204
+ *
205
+ * `{ fresh: true }` reads the document as it is NOW: it skips (and drops)
206
+ * this client's cached copy, is not joined to an in-flight read, and sends
207
+ * `Cache-Control: no-cache` so no HTTP cache on the way answers for the
208
+ * origin. Use it whenever the answer decides where money goes: `#key-1` is
209
+ * the account's Peable receive key and changes on a rotation or an
210
+ * organization key replacement, and a cached copy would derive an address
211
+ * from the previous key for up to the cache TTL.
194
212
  */
195
- async resolveDid(userId: string): Promise<DidDocument> {
196
- return this.ctx.request<DidDocument>(
197
- 'GET',
198
- `/u/${encodeURIComponent(userId)}/did.json`,
199
- undefined,
200
- {
201
- cache: true,
202
- cacheTTL: SHORT_TTL,
203
- },
204
- );
213
+ async resolveDid(userId: string, options: ResolveDidOptions = {}): Promise<DidDocument> {
214
+ const path = `/u/${encodeURIComponent(userId)}/did.json`;
215
+ if (options.fresh) {
216
+ this.ctx.http.invalidateCache({ keys: [`GET:${path}`] });
217
+ return this.ctx.request<DidDocument>('GET', path, undefined, {
218
+ cache: false,
219
+ deduplicate: false,
220
+ headers: { 'Cache-Control': 'no-cache' },
221
+ });
222
+ }
223
+ return this.ctx.request<DidDocument>('GET', path, undefined, {
224
+ cache: true,
225
+ cacheTTL: SHORT_TTL,
226
+ });
205
227
  }
206
228
 
207
229
  /**
@@ -73,6 +73,12 @@ export function deriveScopedSeedFromKey(privateKeyHex: string, info: string): Ui
73
73
  * `HMAC-SHA256(SOCIAL_RECEIVE_CHAIN_CODE_KEY, compressed identity public key)`.
74
74
  * Identical to `@fairco.in/core`'s `deriveSocialReceiveSpendingKey`.
75
75
  *
76
+ * INTERNAL to `@oxy.so/core` (not exported from `@oxy.so/core/crypto`): the
77
+ * derivation is non-hardened and the chain code is a public function of the
78
+ * identity public key, so `identity = child - I_L mod n` — whoever holds one
79
+ * child private key and the public key holds the identity key. Callers get
80
+ * signatures ({@link signSocialReceiveDigest}), never the child key.
81
+ *
76
82
  * @returns the child private key and its compressed public key, lowercase hex.
77
83
  */
78
84
  export function deriveSocialReceiveKey(
@@ -57,13 +57,16 @@ export type { AeadResult } from './aead';
57
57
  export { deriveSharedSecret } from './ecdh';
58
58
 
59
59
  // What the identity key computes without leaving its holder: the same functions
60
- // Commons' identity host implements natively on Android.
60
+ // Commons' identity host implements natively on Android. The social-receive
61
+ // CHILD private key (`deriveSocialReceiveKey`) is deliberately not here: the
62
+ // derivation is non-hardened under a chain code anyone can compute from the
63
+ // public key, so one leaked child private key reveals the identity key. Only
64
+ // signatures (`signSocialReceiveDigest`) leave this module.
61
65
  export {
62
66
  SCOPED_SEED_KDF_SALT,
63
67
  SOCIAL_RECEIVE_CHAIN_CODE_KEY,
64
68
  MAX_SOCIAL_RECEIVE_INDEX,
65
69
  deriveScopedSeedFromKey,
66
- deriveSocialReceiveKey,
67
70
  signSocialReceiveDigest,
68
71
  authChallengeDigest,
69
72
  } from './identityDerivations';
package/src/index.ts CHANGED
@@ -133,6 +133,7 @@ export type {
133
133
  IdentityApi,
134
134
  VerifyDomainResult,
135
135
  RemoveDomainResult,
136
+ ResolveDidOptions,
136
137
  RotateKeyProof,
137
138
  RotateKeyOptions,
138
139
  RotateKeyResult,
@@ -319,8 +320,8 @@ export {
319
320
  getNormalizedUserHandle,
320
321
  } from './utils/userHandle';
321
322
  export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHandle';
322
- export { normalizeProfileLinks } from './utils/profileLinks';
323
- export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
323
+ export { normalizeProfileLinks, supportProviderOf } from './utils/profileLinks';
324
+ export type { ProfileLink, ProfileLinkMetadata, SupportProvider } from './utils/profileLinks';
324
325
 
325
326
  // ---------------------------------------------------------------------------
326
327
  // Auth helpers (token refresh, error normalisation, retry policies)
@@ -13,6 +13,8 @@ export interface ProfileLink {
13
13
  url: string;
14
14
  description?: string;
15
15
  image?: string;
16
+ /** Clarity's favicon for the link's host, when Clarity has one. */
17
+ faviconUrl?: string;
16
18
  }
17
19
 
18
20
  /** Source shape of a single `User.linksMetadata` entry. */
@@ -22,6 +24,7 @@ export interface ProfileLinkMetadata {
22
24
  description?: string;
23
25
  image?: string;
24
26
  id?: string;
27
+ faviconUrl?: string;
25
28
  }
26
29
 
27
30
  function cleanUrl(value: unknown): string | null {
@@ -63,12 +66,14 @@ export function normalizeProfileLinks(
63
66
  const title = typeof entry?.title === 'string' ? entry.title : undefined;
64
67
  const description = typeof entry?.description === 'string' ? entry.description : undefined;
65
68
  const image = typeof entry?.image === 'string' ? entry.image : undefined;
69
+ const faviconUrl = typeof entry?.faviconUrl === 'string' ? entry.faviconUrl : undefined;
66
70
  result.push({
67
71
  id,
68
72
  url,
69
73
  ...(title !== undefined ? { title } : {}),
70
74
  ...(description !== undefined ? { description } : {}),
71
75
  ...(image !== undefined ? { image } : {}),
76
+ ...(faviconUrl !== undefined ? { faviconUrl } : {}),
72
77
  });
73
78
  });
74
79
  return result;
@@ -86,3 +91,42 @@ export function normalizeProfileLinks(
86
91
 
87
92
  return [];
88
93
  }
94
+
95
+ /** A platform a profile can point supporters to, recognised by the link's host. */
96
+ export interface SupportProvider {
97
+ id: 'patreon' | 'kofi' | 'buymeacoffee' | 'github-sponsors' | 'liberapay';
98
+ name: string;
99
+ }
100
+
101
+ const SUPPORT_PROVIDERS: readonly { provider: SupportProvider; hosts: readonly string[] }[] = [
102
+ { provider: { id: 'patreon', name: 'Patreon' }, hosts: ['patreon.com'] },
103
+ { provider: { id: 'kofi', name: 'Ko-fi' }, hosts: ['ko-fi.com'] },
104
+ { provider: { id: 'buymeacoffee', name: 'Buy Me a Coffee' }, hosts: ['buymeacoffee.com'] },
105
+ { provider: { id: 'github-sponsors', name: 'GitHub Sponsors' }, hosts: ['github.com'] },
106
+ { provider: { id: 'liberapay', name: 'Liberapay' }, hosts: ['liberapay.com'] },
107
+ ];
108
+
109
+ /**
110
+ * The support platform a link points at, or `null`. GitHub Sponsors is the one
111
+ * host shared with ordinary GitHub pages, so only its `/sponsors/` path counts.
112
+ */
113
+ export function supportProviderOf(url: string): SupportProvider | null {
114
+ let parsed: URL;
115
+ try {
116
+ parsed = new URL(url);
117
+ } catch {
118
+ return null;
119
+ }
120
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
121
+ for (const { provider, hosts } of SUPPORT_PROVIDERS) {
122
+ if (!hosts.some((known) => host === known || host.endsWith(`.${known}`))) continue;
123
+ if (
124
+ provider.id === 'github-sponsors' &&
125
+ !parsed.pathname.toLowerCase().startsWith('/sponsors/')
126
+ ) {
127
+ continue;
128
+ }
129
+ return provider;
130
+ }
131
+ return null;
132
+ }