@interop/did-method-webvh 3.2.0 → 3.3.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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * ISO 8601 DateTime Validation
3
+ *
4
+ * Inlined from iso-8601-regex (https://github.com/lightningspirit/iso-8601-regex-ts)
5
+ * Licensed under MIT by lightningspirit
6
+ *
7
+ * Enforces strict calendar correctness including leap-year rules and valid day ranges.
8
+ */
9
+ /**
10
+ * Strict ISO 8601 datetime regex with full calendar validation.
11
+ *
12
+ * Enforces:
13
+ * - Year: 0000–9999
14
+ * - Month: 01–12
15
+ * - Day: per month (01–31, 30-day months restricted, Feb 29 allowed only on leap years)
16
+ * - Hour: 00–23
17
+ * - Minute: 00–59
18
+ * - Second: 00–59
19
+ * - Milliseconds: optional, 1–3 digits
20
+ * - Timezone: "Z" (UTC) or ±HH:MM (offset -12:00…+14:00)
21
+ *
22
+ * Leap-year logic (Gregorian calendar):
23
+ * - Divisible by 4 → leap year
24
+ * - Divisible by 100 → not a leap year
25
+ * - Divisible by 400 → leap year again
26
+ *
27
+ * Format: YYYY-MM-DDTHH:mm:ss(.SSS)?(Z|±HH:MM)
28
+ *
29
+ * Valid examples:
30
+ * - "2025-11-02T10:20:30Z" // UTC
31
+ * - "2025-11-02T10:20:30.123Z" // With milliseconds
32
+ * - "2025-11-02T10:20:30+01:00" // With positive offset
33
+ * - "2024-02-29T12:00:00Z" // Leap year (Feb 29 allowed)
34
+ *
35
+ * Invalid examples:
36
+ * - "2025-11-02T10:20:30" // Missing timezone
37
+ * - "2025-04-31T12:00:00Z" // Invalid day for April
38
+ * - "1900-02-29T00:00:00Z" // 1900 not a leap year
39
+ * - "2025-11-02T24:00:00Z" // Invalid hour
40
+ */
41
+ export declare const ISO8601_DATETIME_REGEX: RegExp;
42
+ /**
43
+ * Parse and validate UTC ISO8601 versionTime per did:webvh spec.
44
+ *
45
+ * Enforces:
46
+ * - Strict ISO 8601 format with calendar correctness (via regex)
47
+ * - Timezone MUST be explicit Z or +00:00 (per normative spec language)
48
+ * - Semantic date validity (via Date.parse)
49
+ *
50
+ * Spec reference: did:webvh v1.0 §3.5 and §3.6.2 (`versionTime` in UTC ISO8601)
51
+ * https://identity.foundation/didwebvh/v1.0/
52
+ */
53
+ export declare function parseUtcIso8601VersionTime(value: string, context: string): Date;
54
+ export declare function validateUtcIso8601NotInFuture(value: string, context: string, maxFutureSkewMs?: number, now?: Date): Date;
55
+ export declare function createNextVersionTime(previousVersionTime: string, requestedVersionTime: string | undefined, formatDate: (value: string | Date) => string): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * ISO 8601 DateTime Validation
3
+ *
4
+ * Inlined from iso-8601-regex (https://github.com/lightningspirit/iso-8601-regex-ts)
5
+ * Licensed under MIT by lightningspirit
6
+ *
7
+ * Enforces strict calendar correctness including leap-year rules and valid day ranges.
8
+ */
9
+ /**
10
+ * Strict ISO 8601 datetime regex with full calendar validation.
11
+ *
12
+ * Enforces:
13
+ * - Year: 0000–9999
14
+ * - Month: 01–12
15
+ * - Day: per month (01–31, 30-day months restricted, Feb 29 allowed only on leap years)
16
+ * - Hour: 00–23
17
+ * - Minute: 00–59
18
+ * - Second: 00–59
19
+ * - Milliseconds: optional, 1–3 digits
20
+ * - Timezone: "Z" (UTC) or ±HH:MM (offset -12:00…+14:00)
21
+ *
22
+ * Leap-year logic (Gregorian calendar):
23
+ * - Divisible by 4 → leap year
24
+ * - Divisible by 100 → not a leap year
25
+ * - Divisible by 400 → leap year again
26
+ *
27
+ * Format: YYYY-MM-DDTHH:mm:ss(.SSS)?(Z|±HH:MM)
28
+ *
29
+ * Valid examples:
30
+ * - "2025-11-02T10:20:30Z" // UTC
31
+ * - "2025-11-02T10:20:30.123Z" // With milliseconds
32
+ * - "2025-11-02T10:20:30+01:00" // With positive offset
33
+ * - "2024-02-29T12:00:00Z" // Leap year (Feb 29 allowed)
34
+ *
35
+ * Invalid examples:
36
+ * - "2025-11-02T10:20:30" // Missing timezone
37
+ * - "2025-04-31T12:00:00Z" // Invalid day for April
38
+ * - "1900-02-29T00:00:00Z" // 1900 not a leap year
39
+ * - "2025-11-02T24:00:00Z" // Invalid hour
40
+ */
41
+ export const ISO8601_DATETIME_REGEX = new RegExp('^' +
42
+ '(?<year>\\d{4})-' +
43
+ '(?<month>(?:0[1-9]|1[0-2]))-' +
44
+ '(?<day>' +
45
+ '(?:' +
46
+ '(?<=\\d{4}-(?:01|03|05|07|08|10|12)-)(?:0[1-9]|[12]\\d|3[01])|' + // 31-day months
47
+ '(?<=\\d{4}-(?:04|06|09|11)-)(?:0[1-9]|[12]\\d|30)|' + // 30-day months
48
+ '(?<=\\d{4}-02-)(?:0[1-9]|1\\d|2[0-8])|' + // Feb 01-28
49
+ '(?<=(' +
50
+ '(?:\\d{2}(?:0[48]|[2468][048]|[13579][26]))' + // yy % 4 == 0 (leap year non-century)
51
+ '|(?:(?:[02468][048]|[13579][26])00)' + // centuries % 400 == 0 (leap year century)
52
+ ')-02-)29' + // Feb 29 only if preceding matches leap year
53
+ ')' +
54
+ ')' +
55
+ 'T' +
56
+ '(?<hour>(?:[01]\\d|2[0-3])):' +
57
+ '(?<minute>[0-5]\\d):' +
58
+ '(?<second>[0-5]\\d)' +
59
+ '(?:\\.(?<millisecond>\\d{1,3}))?' + // optional .sss
60
+ '(?<timezone>' +
61
+ 'Z|' + // UTC
62
+ '(?:' +
63
+ '\\+(?:(?:0\\d|1[0-3]):[0-5]\\d|14:00)|' + // +00:00…+13:59 or +14:00
64
+ '-(?:(?:0\\d|1[01]):[0-5]\\d|12:00)' + // -00:00…-11:59 or -12:00
65
+ ')' +
66
+ ')' +
67
+ '$');
68
+ /**
69
+ * Parse and validate UTC ISO8601 versionTime per did:webvh spec.
70
+ *
71
+ * Enforces:
72
+ * - Strict ISO 8601 format with calendar correctness (via regex)
73
+ * - Timezone MUST be explicit Z or +00:00 (per normative spec language)
74
+ * - Semantic date validity (via Date.parse)
75
+ *
76
+ * Spec reference: did:webvh v1.0 §3.5 and §3.6.2 (`versionTime` in UTC ISO8601)
77
+ * https://identity.foundation/didwebvh/v1.0/
78
+ */
79
+ export function parseUtcIso8601VersionTime(value, context) {
80
+ const match = ISO8601_DATETIME_REGEX.exec(value);
81
+ const parsed = new Date(value);
82
+ if (!match || Number.isNaN(parsed.getTime())) {
83
+ throw new Error(`${context} must be a valid UTC ISO8601 timestamp`);
84
+ }
85
+ // Per spec, only Z or +00:00 (explicit UTC) are allowed
86
+ const timezone = match.groups?.timezone;
87
+ if (timezone && timezone !== 'Z' && timezone !== '+00:00') {
88
+ throw new Error(`${context} must be in UTC (Z or +00:00), found ${timezone}`);
89
+ }
90
+ return parsed;
91
+ }
92
+ export function validateUtcIso8601NotInFuture(value, context, maxFutureSkewMs = 0, now = new Date()) {
93
+ const parsed = parseUtcIso8601VersionTime(value, context);
94
+ if (parsed.getTime() > now.getTime() + maxFutureSkewMs) {
95
+ if (maxFutureSkewMs > 0) {
96
+ throw new Error(`${context} must not be more than ${maxFutureSkewMs / 60000} minutes in the future`);
97
+ }
98
+ throw new Error(`${context} must not be in the future`);
99
+ }
100
+ return parsed;
101
+ }
102
+ export function createNextVersionTime(previousVersionTime, requestedVersionTime, formatDate) {
103
+ const previous = parseUtcIso8601VersionTime(previousVersionTime, 'previous versionTime');
104
+ if (requestedVersionTime) {
105
+ const requested = parseUtcIso8601VersionTime(requestedVersionTime, 'requested versionTime');
106
+ if (requested.getTime() <= previous.getTime()) {
107
+ throw new Error('versionTime must be greater than previous versionTime');
108
+ }
109
+ return formatDate(requestedVersionTime);
110
+ }
111
+ const now = new Date();
112
+ if (now.getTime() <= previous.getTime()) {
113
+ return formatDate(new Date(previous.getTime() + 1));
114
+ }
115
+ return formatDate(now);
116
+ }
@@ -98,3 +98,5 @@ export declare function decodeMultihashFromMultibase(str: string): {
98
98
  digest: Uint8Array;
99
99
  encoding: MultibaseEncoding;
100
100
  };
101
+ /** Multicodec prefix (`0xed 0x01`) for an Ed25519 public key multikey. */
102
+ export declare function isEd25519Multikey(keyBytes: Uint8Array): boolean;
@@ -281,3 +281,7 @@ export function decodeMultihashFromMultibase(str) {
281
281
  encoding,
282
282
  };
283
283
  }
284
+ /** Multicodec prefix (`0xed 0x01`) for an Ed25519 public key multikey. */
285
+ export function isEd25519Multikey(keyBytes) {
286
+ return keyBytes.length >= 2 && keyBytes[0] === 0xed && keyBytes[1] === 0x01;
287
+ }
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CreateDIDInterface, DIDDoc, DIDLog, ParsedDidKeyVerificationMethod, VerificationMethod, WitnessProofFileEntry } from './interfaces.js';
1
+ import type { CreateDIDInterface, DIDDoc, DIDLog, ParsedDidKeyVerificationMethod, ServiceEndpoint, VerificationMethod, WitnessProofFileEntry } from './interfaces.js';
2
2
  export declare function parseDidKeyDid(input: string): {
3
3
  did: string;
4
4
  keyMultibase: string;
@@ -10,7 +10,15 @@ interface ParsedAddress {
10
10
  didDomainComponent: string;
11
11
  paths?: string[];
12
12
  }
13
+ export interface ParsedDidWebvhIdentifier {
14
+ scid: string;
15
+ didDomainComponent: string;
16
+ paths?: string[];
17
+ locationKey: string;
18
+ }
19
+ export declare function validateMethodSpecificPathSegments(pathSegments: string[], context: string): void;
13
20
  export declare function parseCanonicalAddress(input: string): ParsedAddress;
21
+ export declare function parseDidWebvhIdentifier(did: string, context: string): ParsedDidWebvhIdentifier;
14
22
  export declare const DID_PLACEHOLDER = "{DID}";
15
23
  export declare function validateCreateDidDocument(didDocument: DIDDoc): void;
16
24
  export declare function replaceCreateDidPlaceholders<T>(input: T, scid: string, did: string): T;
@@ -18,6 +26,11 @@ export declare function convertWebvhIdToWebId(id: string): string;
18
26
  export declare function enrichAlsoKnownAs(doc: DIDDoc, did: string, opts: {
19
27
  alsoKnownAsWeb?: boolean;
20
28
  }): DIDDoc;
29
+ /**
30
+ * Check if a service with the given fragment exists in the service array.
31
+ * Matches both fragment form (e.g., '#files') and absolute form (e.g., 'did:webvh:...#files').
32
+ */
33
+ export declare function serviceFragmentExists(services: ServiceEndpoint[], fragment: string, did: string): boolean;
21
34
  export declare function generateParallelDidWeb(didwebvhDid: string, didwebvhDoc: DIDDoc): DIDDoc;
22
35
  export declare const readLogFromDisk: (path: string) => Promise<DIDLog>;
23
36
  export declare const readLogFromString: (str: string) => DIDLog;
@@ -43,7 +56,7 @@ export declare const normalizeVMs: (verificationMethod: VerificationMethod[] | u
43
56
  export declare const resolveVM: (vm: string) => Promise<VerificationMethod | {
44
57
  publicKeyMultibase: string;
45
58
  } | null>;
46
- export declare const findVerificationMethod: (doc: any, vmId: string) => VerificationMethod | null;
59
+ export declare const findVerificationMethod: (doc: DIDDoc, vmId: string) => VerificationMethod | null;
47
60
  export declare function fetchWitnessProofs(did: string): Promise<WitnessProofFileEntry[]>;
48
61
  export declare function replaceValueInObject(obj: any, searchValue: string, replaceValue: string): any;
49
62
  export {};
package/dist/utils.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { config } from './config.js';
2
- import { BASE_CONTEXT } from './constants.js';
2
+ import { BASE_CONTEXT, CONTEXT_LINKED_VP, METHOD, SERVICE_TYPE_LINKED_VP, SERVICE_TYPE_RELATIVE_REF, } from './constants.js';
3
3
  import { resolveDIDFromLog } from './method.js';
4
4
  import { bufferToString, createBuffer } from './utils/buffer.js';
5
5
  import { canonicalizeStrict } from './utils/canonicalize.js';
@@ -14,7 +14,8 @@ function validateDidKeyMultibase(keyMultibase) {
14
14
  multibaseDecode(keyMultibase);
15
15
  }
16
16
  catch (error) {
17
- throw new Error(`Malformed did:key identifier: ${error.message}`);
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ throw new Error(`Malformed did:key identifier: ${message}`);
18
19
  }
19
20
  }
20
21
  export function parseDidKeyDid(input) {
@@ -45,6 +46,11 @@ export function parseDidKeyVerificationMethod(input) {
45
46
  }
46
47
  const parsedDid = parseDidKeyDid(`${DID_KEY_PREFIX}${match[1]}`);
47
48
  const fragment = match[2];
49
+ // If fragment is present, it MUST equal the body multibase exactly
50
+ if (fragment && fragment !== parsedDid.keyMultibase) {
51
+ throw new Error(`did:key verificationMethod fragment must equal body multibase. ` +
52
+ `Expected fragment '${parsedDid.keyMultibase}' but got '${fragment}'`);
53
+ }
48
54
  return {
49
55
  did: parsedDid.did,
50
56
  fragment,
@@ -65,44 +71,120 @@ function isDoubleEncoded(value) {
65
71
  // Detect %25 (which is percent-encoded %)
66
72
  return value.includes('%25');
67
73
  }
74
+ function hasFragmentOrQuery(value) {
75
+ return value.includes('#') || value.includes('?');
76
+ }
77
+ function decodeHostComponent(host) {
78
+ try {
79
+ return decodeURIComponent(host);
80
+ }
81
+ catch {
82
+ throw new Error(`Invalid percent-encoding in host: ${host}`);
83
+ }
84
+ }
85
+ function parsePortNumber(rawPort) {
86
+ const portNum = parseInt(rawPort, 10);
87
+ if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
88
+ throw new Error(`Invalid port number: ${rawPort}`);
89
+ }
90
+ return portNum;
91
+ }
92
+ function toOptionalPaths(pathSegments) {
93
+ return pathSegments.length > 0 ? pathSegments : undefined;
94
+ }
95
+ function toDidDomainComponent(host, port) {
96
+ return port ? `${host}%3A${port}` : host;
97
+ }
98
+ function toParsedAddress(host, port, paths = []) {
99
+ return {
100
+ canonicalHost: host,
101
+ canonicalPort: port,
102
+ didDomainComponent: toDidDomainComponent(host, port),
103
+ paths: toOptionalPaths(paths),
104
+ };
105
+ }
106
+ function parseRawHostPort(input) {
107
+ if (!input.includes(':')) {
108
+ return { host: input };
109
+ }
110
+ const parts = input.split(':');
111
+ if (parts.length !== 2) {
112
+ throw new Error('Invalid host:port format');
113
+ }
114
+ return {
115
+ host: parts[0],
116
+ port: parsePortNumber(parts[1]),
117
+ };
118
+ }
119
+ function parseEncodedPortComponent(value) {
120
+ const encodedSeparator = /%3a/i;
121
+ if (!encodedSeparator.test(value)) {
122
+ return { host: value };
123
+ }
124
+ const parts = value.split(encodedSeparator);
125
+ if (parts.length !== 2) {
126
+ throw new Error('Invalid pre-encoded port separator');
127
+ }
128
+ const [host, rawPort] = parts;
129
+ return { host, port: parsePortNumber(rawPort) };
130
+ }
131
+ export function validateMethodSpecificPathSegments(pathSegments, context) {
132
+ for (const segment of pathSegments) {
133
+ let decodedSegment;
134
+ try {
135
+ decodedSegment = decodeURIComponent(segment);
136
+ }
137
+ catch {
138
+ throw new Error(`${context} contains invalid percent-encoding in path segment '${segment}'`);
139
+ }
140
+ if (decodedSegment === '.' || decodedSegment === '..') {
141
+ throw new Error(`${context} must not contain dot-segments`);
142
+ }
143
+ if (decodedSegment.includes('/')) {
144
+ throw new Error(`${context} must not contain decoded slash within a single path segment`);
145
+ }
146
+ if (decodedSegment.includes('\\')) {
147
+ throw new Error(`${context} must not contain decoded backslash within a path segment`);
148
+ }
149
+ if (decodedSegment.includes('\u0000')) {
150
+ throw new Error(`${context} must not contain decoded NUL character within a path segment`);
151
+ }
152
+ if (decodedSegment !== decodedSegment.trim()) {
153
+ throw new Error(`${context} must not contain leading or trailing whitespace in decoded path segment`);
154
+ }
155
+ }
156
+ }
68
157
  export function parseCanonicalAddress(input) {
69
158
  if (!input || typeof input !== 'string') {
70
159
  throw new Error('Address input must be a non-empty string');
71
160
  }
161
+ if (hasFragmentOrQuery(input) && !input.startsWith('http://') && !input.startsWith('https://')) {
162
+ throw new Error('Address input must not include query or fragment components');
163
+ }
72
164
  // Parse did:webvh form
73
165
  if (input.startsWith('did:webvh:')) {
74
166
  const parts = input.substring(10).split(':');
75
167
  if (parts.length < 2) {
76
168
  throw new Error('Invalid did:webvh identifier: must contain SCID (or {SCID} placeholder) and domain');
77
169
  }
78
- const scid = parts[0];
79
170
  const domainPart = parts[1];
80
171
  const pathParts = parts.slice(2);
172
+ if (hasFragmentOrQuery(domainPart) || pathParts.some((segment) => hasFragmentOrQuery(segment))) {
173
+ throw new Error('did:webvh identifier must not include query or fragment components');
174
+ }
175
+ validateMethodSpecificPathSegments(pathParts, 'did:webvh identifier');
81
176
  // Detect double encoding
82
177
  if (isDoubleEncoded(domainPart)) {
83
178
  throw new Error('Domain is double-encoded (detected %25)');
84
179
  }
85
180
  // Extract port from domain if %3A-encoded
86
- let host = domainPart;
87
- let port;
88
- if (domainPart.includes('%3A')) {
89
- const [h, p] = domainPart.split('%3A');
90
- host = h;
91
- const portNum = parseInt(p, 10);
92
- if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
93
- throw new Error(`Invalid port number: ${p}`);
94
- }
95
- port = portNum;
96
- }
181
+ const parsedPort = parseEncodedPortComponent(domainPart);
182
+ const host = decodeHostComponent(parsedPort.host);
183
+ const port = parsedPort.port;
97
184
  if (isIPAddress(host)) {
98
185
  throw new Error('IP addresses are not allowed as hosts');
99
186
  }
100
- return {
101
- canonicalHost: host,
102
- canonicalPort: port,
103
- didDomainComponent: domainPart,
104
- paths: pathParts.length > 0 ? pathParts : undefined,
105
- };
187
+ return toParsedAddress(host, port, pathParts);
106
188
  }
107
189
  // Parse URL form: HTTPS everywhere, with localhost-only HTTP for local testing.
108
190
  if (input.startsWith('https://') || input.startsWith('http://')) {
@@ -111,35 +193,23 @@ export function parseCanonicalAddress(input) {
111
193
  if (url.protocol === 'http:' && url.hostname !== 'localhost') {
112
194
  throw new Error('HTTP is only allowed for localhost; use HTTPS for non-local hosts');
113
195
  }
196
+ if (url.hash || url.search) {
197
+ throw new Error('URL input must not include query or fragment components');
198
+ }
114
199
  const host = url.hostname;
115
200
  const port = url.port ? parseInt(url.port, 10) : undefined;
116
201
  if (isIPAddress(host)) {
117
202
  throw new Error('IP addresses are not allowed as hosts');
118
203
  }
119
- let didDomainComponent = host;
120
- if (port) {
121
- didDomainComponent += `%3A${port}`;
122
- }
123
- const pathParts = [];
124
- if (url.pathname && url.pathname !== '/') {
125
- url.pathname
126
- .split('/')
127
- .filter((p) => p.length > 0)
128
- .forEach((p) => {
129
- pathParts.push(p);
130
- });
131
- }
132
- return {
133
- canonicalHost: host,
134
- canonicalPort: port,
135
- didDomainComponent,
136
- paths: pathParts.length > 0 ? pathParts : undefined,
137
- };
204
+ const pathParts = url.pathname && url.pathname !== '/' ? url.pathname.split('/').filter((p) => p.length > 0) : [];
205
+ validateMethodSpecificPathSegments(pathParts, 'URL pathname');
206
+ return toParsedAddress(host, port, pathParts);
138
207
  }
139
208
  catch (e) {
140
- if (e.message?.includes('not allowed'))
209
+ const message = e instanceof Error ? e.message : String(e);
210
+ if (message.includes('not allowed'))
141
211
  throw e;
142
- throw new Error(`Invalid URL: ${e.message}`);
212
+ throw new Error(`Invalid URL: ${message}`);
143
213
  }
144
214
  }
145
215
  // Parse domain string form (host or host:port)
@@ -147,45 +217,35 @@ export function parseCanonicalAddress(input) {
147
217
  if (isDoubleEncoded(input)) {
148
218
  throw new Error('Domain is double-encoded (detected %25)');
149
219
  }
150
- let host = input;
151
- let port;
152
- // Check if pre-encoded with %3A
153
- if (input.includes('%3A')) {
154
- const parts = input.split('%3A');
155
- if (parts.length !== 2) {
156
- throw new Error('Invalid pre-encoded port separator');
157
- }
158
- host = parts[0];
159
- const portNum = parseInt(parts[1], 10);
160
- if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
161
- throw new Error(`Invalid port number: ${parts[1]}`);
162
- }
163
- port = portNum;
164
- }
165
- else if (input.includes(':')) {
166
- // Raw host:port form
167
- const parts = input.split(':');
168
- if (parts.length !== 2) {
169
- throw new Error('Invalid host:port format');
170
- }
171
- host = parts[0];
172
- const portNum = parseInt(parts[1], 10);
173
- if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
174
- throw new Error(`Invalid port number: ${parts[1]}`);
175
- }
176
- port = portNum;
220
+ if (hasFragmentOrQuery(input)) {
221
+ throw new Error('Domain input must not include query or fragment components');
177
222
  }
223
+ const hostAndPort = /%3a/i.test(input) ? parseEncodedPortComponent(input) : parseRawHostPort(input);
224
+ const host = decodeHostComponent(hostAndPort.host);
225
+ const port = hostAndPort.port;
178
226
  if (isIPAddress(host)) {
179
227
  throw new Error('IP addresses are not allowed as hosts');
180
228
  }
181
- let didDomainComponent = host;
182
- if (port) {
183
- didDomainComponent += `%3A${port}`;
184
- }
229
+ return toParsedAddress(host, port);
230
+ }
231
+ export function parseDidWebvhIdentifier(did, context) {
232
+ const parsedAddress = parseCanonicalAddress(did);
233
+ const didParts = did.split(':');
234
+ if (didParts.length < 4 || didParts[0] !== 'did' || didParts[1] !== METHOD) {
235
+ throw new Error(`${context} must be a valid did:webvh identifier`);
236
+ }
237
+ const scid = didParts[2];
238
+ if (!scid) {
239
+ throw new Error(`${context} must include SCID segment`);
240
+ }
241
+ const locationKey = parsedAddress.paths?.length
242
+ ? `${parsedAddress.didDomainComponent}:${parsedAddress.paths.join(':')}`
243
+ : parsedAddress.didDomainComponent;
185
244
  return {
186
- canonicalHost: host,
187
- canonicalPort: port,
188
- didDomainComponent,
245
+ scid,
246
+ didDomainComponent: parsedAddress.didDomainComponent,
247
+ paths: parsedAddress.paths,
248
+ locationKey,
189
249
  };
190
250
  }
191
251
  // Environment detection - treat React Native like a browser, but Bun as Node-like
@@ -252,6 +312,18 @@ export function enrichAlsoKnownAs(doc, did, opts) {
252
312
  alsoKnownAs: aliases,
253
313
  };
254
314
  }
315
+ /**
316
+ * Check if a service with the given fragment exists in the service array.
317
+ * Matches both fragment form (e.g., '#files') and absolute form (e.g., 'did:webvh:...#files').
318
+ */
319
+ export function serviceFragmentExists(services, fragment, did) {
320
+ const fragmentForm = `#${fragment}`;
321
+ const absoluteForm = `${did}#${fragment}`;
322
+ return services.some((s) => {
323
+ const serviceId = s.id || '';
324
+ return serviceId === fragmentForm || serviceId === absoluteForm;
325
+ });
326
+ }
255
327
  export function generateParallelDidWeb(didwebvhDid, didwebvhDoc) {
256
328
  let webDoc = deepClone(didwebvhDoc);
257
329
  const domainPath = didwebvhDid.replace(/^did:webvh:[^:]+:/, '');
@@ -261,15 +333,15 @@ export function generateParallelDidWeb(didwebvhDid, didwebvhDoc) {
261
333
  if (!existingServiceIds.some((id) => id.endsWith('#files'))) {
262
334
  implicitServices.push({
263
335
  id: '#files',
264
- type: 'relativeRef',
336
+ type: SERVICE_TYPE_RELATIVE_REF,
265
337
  serviceEndpoint: httpsBase,
266
338
  });
267
339
  }
268
340
  if (!existingServiceIds.some((id) => id.endsWith('#whois'))) {
269
341
  implicitServices.push({
270
- '@context': 'https://identity.foundation/linked-vp/contexts/v1',
342
+ '@context': CONTEXT_LINKED_VP,
271
343
  id: '#whois',
272
- type: 'LinkedVerifiablePresentation',
344
+ type: SERVICE_TYPE_LINKED_VP,
273
345
  serviceEndpoint: `${httpsBase}whois.vp`,
274
346
  });
275
347
  }
@@ -345,7 +417,8 @@ export const writeVerificationMethodToEnv = async (verificationMethod) => {
345
417
  const match = envContent.match(/DID_VERIFICATION_METHODS=(.*)/);
346
418
  if (match?.[1]) {
347
419
  const decodedData = bufferToString(createBuffer(match[1], 'base64'));
348
- existingData = JSON.parse(decodedData);
420
+ const parsedData = JSON.parse(decodedData);
421
+ existingData = Array.isArray(parsedData) ? parsedData : [];
349
422
  // Check if verification method with same ID already exists
350
423
  const existingIndex = existingData.findIndex((vm) => vm.id === vmData.id);
351
424
  if (existingIndex !== -1) {
@@ -398,17 +471,15 @@ export function deepClone(obj) {
398
471
  return cloned;
399
472
  }
400
473
  export const getBaseUrl = (id) => {
401
- const parts = id.split(':');
402
- if (!id.startsWith('did:webvh:') || parts.length < 4) {
403
- throw new Error(`${id} is not a valid did:webvh identifier`);
404
- }
405
- const remainder = decodeURIComponent(parts.slice(3).join('/'));
406
- const protocol = remainder.includes('localhost') ? 'http' : 'https';
407
- const [hostPart, ...pathParts] = remainder.split('/');
408
- let [host, port] = decodeURIComponent(hostPart).split(':');
409
- host = toASCII(host.normalize('NFC'));
410
- const normalizedHost = port ? `${host}:${port}` : host;
411
- const path = pathParts.join('/');
474
+ if (hasFragmentOrQuery(id)) {
475
+ throw new Error('did:webvh identifier must not include query or fragment components');
476
+ }
477
+ const parsed = parseCanonicalAddress(id);
478
+ // This fork allows http for localhost (local testing); HTTPS everywhere else.
479
+ const protocol = parsed.canonicalHost === 'localhost' ? 'http' : 'https';
480
+ const host = toASCII(parsed.canonicalHost.normalize('NFC'));
481
+ const normalizedHost = parsed.canonicalPort ? `${host}:${parsed.canonicalPort}` : host;
482
+ const path = parsed.paths?.join('/') ?? '';
412
483
  return `${protocol}://${normalizedHost}${path ? `/${path}` : ''}`;
413
484
  };
414
485
  export const getFileUrl = (id) => {
@@ -437,7 +508,7 @@ export async function fetchLogFromIdentifier(identifier) {
437
508
  throw error;
438
509
  }
439
510
  }
440
- export const createDate = (created) => `${new Date(created ?? Date.now()).toISOString().slice(0, -5)}Z`;
511
+ export const createDate = (created) => new Date(created ?? Date.now()).toISOString();
441
512
  export function bytesToHex(bytes) {
442
513
  return Array.from(bytes)
443
514
  .map((byte) => byte.toString(16).padStart(2, '0'))
@@ -608,6 +679,9 @@ export const resolveVM = async (vm) => {
608
679
  .split('\n')
609
680
  .map((l) => JSON.parse(l));
610
681
  const { doc } = await resolveDIDFromLog(logEntries, { verificationMethod: vm });
682
+ if (!doc) {
683
+ throw new Error(`Verification method ${vm} not found`);
684
+ }
611
685
  return findVerificationMethod(doc, vm);
612
686
  }
613
687
  throw new Error(`Verification method ${vm} not found`);
@@ -619,7 +693,7 @@ export const resolveVM = async (vm) => {
619
693
  export const findVerificationMethod = (doc, vmId) => {
620
694
  // Check in the verificationMethod array
621
695
  if (doc.verificationMethod?.some((vm) => vm.id === vmId)) {
622
- return doc.verificationMethod.find((vm) => vm.id === vmId);
696
+ return doc.verificationMethod.find((vm) => vm.id === vmId) ?? null;
623
697
  }
624
698
  // Check in other verification method relationship arrays
625
699
  const vmRelationships = [
@@ -630,9 +704,22 @@ export const findVerificationMethod = (doc, vmId) => {
630
704
  'capabilityDelegation',
631
705
  ];
632
706
  for (const relationship of vmRelationships) {
633
- if (doc[relationship]) {
634
- if (doc[relationship].some((item) => item.id === vmId)) {
635
- return doc[relationship].find((item) => item.id === vmId);
707
+ const relationshipValues = doc[relationship];
708
+ if (Array.isArray(relationshipValues) &&
709
+ relationshipValues.some((item) => {
710
+ if (typeof item !== 'object' || item === null)
711
+ return false;
712
+ const maybeId = item.id;
713
+ return maybeId === vmId;
714
+ })) {
715
+ const match = relationshipValues.find((item) => {
716
+ if (typeof item !== 'object' || item === null)
717
+ return false;
718
+ const maybeId = item.id;
719
+ return maybeId === vmId;
720
+ });
721
+ if (match && typeof match === 'object') {
722
+ return match;
636
723
  }
637
724
  }
638
725
  }