@nextera.one/tps-standard 0.4.3 → 0.5.1

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.
package/src/index.ts CHANGED
@@ -2,9 +2,15 @@
2
2
  * TPS: Temporal Positioning System
3
3
  * The Universal Protocol for Space-Time Coordinates.
4
4
  * @packageDocumentation
5
- * @version 0.4.2
6
- * @license MIT
5
+ * @version 0.5.0
6
+ * @license Apache-2.0
7
7
  * @copyright 2026 TPS Standards Working Group
8
+ *
9
+ * v0.5.0 Changes:
10
+ * - Added Actor anchor (A:) for provenance tracking
11
+ * - Added Signature (!) for cryptographic verification
12
+ * - Added structural anchors (bldg, floor, room, zone)
13
+ * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
8
14
  */
9
15
 
10
16
  export type CalendarCode = 'greg' | 'hij' | 'jul' | 'holo' | 'unix';
@@ -22,11 +28,32 @@ export interface TPSComponents {
22
28
  second?: number;
23
29
  unixSeconds?: number;
24
30
 
25
- // --- SPATIAL ---
31
+ // --- SPATIAL: GPS Coordinates ---
26
32
  latitude?: number;
27
33
  longitude?: number;
28
34
  altitude?: number;
29
35
 
36
+ // --- SPATIAL: Geospatial Cells ---
37
+ /** Google S2 cell ID (hierarchical, prefix-searchable) */
38
+ s2Cell?: string;
39
+ /** Uber H3 cell ID (hexagonal grid) */
40
+ h3Cell?: string;
41
+ /** Open Location Code / Plus Code */
42
+ plusCode?: string;
43
+ /** what3words address (e.g. "filled.count.soap") */
44
+ what3words?: string;
45
+
46
+ // --- SPATIAL: Structural Anchors ---
47
+ /** Physical building identifier */
48
+ building?: string;
49
+ /** Vertical division (level) */
50
+ floor?: string;
51
+ /** Enclosed space identifier */
52
+ room?: string;
53
+ /** Logical area within building */
54
+ zone?: string;
55
+
56
+ // --- SPATIAL: Privacy Markers ---
30
57
  /** Technical missing data (e.g. server log without GPS) */
31
58
  isUnknownLocation?: boolean;
32
59
  /** Removed for legal/security reasons (e.g. GDPR) */
@@ -34,6 +61,12 @@ export interface TPSComponents {
34
61
  /** Masked by user preference (e.g. "Don't show my location") */
35
62
  isHiddenLocation?: boolean;
36
63
 
64
+ // --- PROVENANCE ---
65
+ /** Actor anchor - identifies observer/witness (e.g. "did:web:sensor.example.com", "node:gateway-01") */
66
+ actor?: string;
67
+ /** Verification hash appended to time (e.g. "sha256:8f3e2a...") */
68
+ signature?: string;
69
+
37
70
  // --- CONTEXT ---
38
71
  extensions?: Record<string, string>;
39
72
  }
@@ -42,12 +75,63 @@ export interface TPSComponents {
42
75
 
43
76
  /**
44
77
  * Interface for Calendar Driver plugins.
45
- * Implementations must provide conversion logic to/from Gregorian.
78
+ * Implementations provide conversion logic to/from Gregorian and support for
79
+ * external calendar libraries.
80
+ *
81
+ * @example Using a driver to parse a Hijri date string
82
+ * ```ts
83
+ * const driver = TPS.getDriver('hij');
84
+ * if (driver?.parseDate) {
85
+ * const components = driver.parseDate('1447-07-21');
86
+ * const gregDate = driver.toGregorian(components);
87
+ * const tpsString = TPS.fromDate(gregDate, 'hij');
88
+ * }
89
+ * ```
90
+ *
91
+ * @example Wrapping an external library (moment-hijri)
92
+ * ```ts
93
+ * import moment from 'moment-hijri';
94
+ *
95
+ * class HijriDriver implements CalendarDriver {
96
+ * readonly code = 'hij';
97
+ *
98
+ * parseDate(input: string, format?: string): Partial<TPSComponents> {
99
+ * const m = moment(input, format || 'iYYYY-iMM-iDD');
100
+ * return {
101
+ * calendar: 'hij',
102
+ * year: m.iYear(),
103
+ * month: m.iMonth() + 1,
104
+ * day: m.iDate()
105
+ * };
106
+ * }
107
+ *
108
+ * fromGregorian(date: Date): Partial<TPSComponents> {
109
+ * const m = moment(date);
110
+ * return {
111
+ * calendar: 'hij',
112
+ * year: m.iYear(),
113
+ * month: m.iMonth() + 1,
114
+ * day: m.iDate(),
115
+ * hour: m.hour(),
116
+ * minute: m.minute(),
117
+ * second: m.second()
118
+ * };
119
+ * }
120
+ *
121
+ * // ... other methods
122
+ * }
123
+ * ```
46
124
  */
47
125
  export interface CalendarDriver {
48
126
  /** The calendar code this driver handles (e.g., 'hij', 'jul'). */
49
127
  readonly code: CalendarCode;
50
128
 
129
+ /**
130
+ * Human-readable name for this calendar (optional).
131
+ * @example "Hijri (Islamic)"
132
+ */
133
+ readonly name?: string;
134
+
51
135
  /**
52
136
  * Converts a Gregorian Date to this calendar's components.
53
137
  * @param date - The Gregorian Date object.
@@ -65,9 +149,94 @@ export interface CalendarDriver {
65
149
  /**
66
150
  * Generates a TPS time string for this calendar from a Date.
67
151
  * @param date - The Gregorian Date object.
68
- * @returns A TPS time string (e.g., "T:hij.m2.c5.y47...").
152
+ * @returns A TPS time string (e.g., "T:hij.y1447.M07.d21...").
69
153
  */
70
154
  fromDate(date: Date): string;
155
+
156
+ // --- NEW ENHANCED METHODS (Optional) ---
157
+
158
+ /**
159
+ * Parse a calendar-specific date string into TPS components.
160
+ * This allows drivers to handle native date formats from external libraries.
161
+ *
162
+ * @param input - Date string in calendar-native format (e.g., '1447-07-21' for Hijri)
163
+ * @param format - Optional format string (driver-specific, e.g., 'iYYYY-iMM-iDD')
164
+ * @returns Partial TPS components
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * // Hijri driver
169
+ * driver.parseDate('1447-07-21'); // → { year: 1447, month: 7, day: 21, calendar: 'hij' }
170
+ *
171
+ * // With time
172
+ * driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
173
+ * ```
174
+ */
175
+ parseDate?(input: string, format?: string): Partial<TPSComponents>;
176
+
177
+ /**
178
+ * Format TPS components to a calendar-specific date string.
179
+ * Inverse of parseDate().
180
+ *
181
+ * @param components - TPS components to format
182
+ * @param format - Optional format string (driver-specific)
183
+ * @returns Formatted date string in calendar-native format
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * driver.format({ year: 1447, month: 7, day: 21 }); // → '1447-07-21'
188
+ * driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
189
+ * ```
190
+ */
191
+ format?(components: Partial<TPSComponents>, format?: string): string;
192
+
193
+ /**
194
+ * Validate a calendar-specific date string or components.
195
+ *
196
+ * @param input - Date string or components to validate
197
+ * @returns true if valid for this calendar
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * driver.validate('1447-13-01'); // → false (month 13 invalid)
202
+ * driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
203
+ * ```
204
+ */
205
+ validate?(input: string | Partial<TPSComponents>): boolean;
206
+
207
+ /**
208
+ * Get calendar metadata (month names, day names, etc.).
209
+ * Useful for UI rendering.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * driver.getMetadata().monthNames
214
+ * // → ['Muharram', 'Safar', 'Rabi I', ...]
215
+ * ```
216
+ */
217
+ getMetadata?(): CalendarMetadata;
218
+ }
219
+
220
+ /**
221
+ * Metadata about a calendar system.
222
+ */
223
+ export interface CalendarMetadata {
224
+ /** Human-readable calendar name */
225
+ name: string;
226
+ /** Month names in order (1-12 or 1-13) */
227
+ monthNames?: string[];
228
+ /** Short month names */
229
+ monthNamesShort?: string[];
230
+ /** Day of week names (Sunday=0 or locale-specific) */
231
+ dayNames?: string[];
232
+ /** Short day names */
233
+ dayNamesShort?: string[];
234
+ /** Whether this calendar is lunar-based */
235
+ isLunar?: boolean;
236
+ /** Number of months per year */
237
+ monthsPerYear?: number;
238
+ /** Epoch year (for reference) */
239
+ epochYear?: number;
71
240
  }
72
241
 
73
242
  export class TPS {
@@ -93,12 +262,40 @@ export class TPS {
93
262
  }
94
263
 
95
264
  // --- REGEX ---
265
+ // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
266
+ // Note: Complex regex - carefully balanced parentheses
96
267
  private static readonly REGEX_URI = new RegExp(
97
- '^tps://(?<space>unknown|redacted|hidden|(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?)@T:(?<calendar>[a-z]{3,4})\\.(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)?(?:;(?<extensions>[a-z0-9\\.\\-\\_]+))?$',
268
+ '^tps://' +
269
+ // Location part (L: prefix optional for backward compat)
270
+ '(?:L:)?(?<space>' +
271
+ '~|-|unknown|redacted|hidden|' + // Privacy markers
272
+ 's2=(?<s2>[a-fA-F0-9]+)|' + // S2 cell
273
+ 'h3=(?<h3>[a-fA-F0-9]+)|' + // H3 cell
274
+ 'plus=(?<plus>[A-Z0-9+]+)|' + // Plus Code
275
+ 'w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|' + // what3words
276
+ 'bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|' + // Structural
277
+ '(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?' + // GPS
278
+ ')' +
279
+ // Optional Actor anchor
280
+ '(?:/A:(?<actor>[^/@]+))?' +
281
+ // Time part separator
282
+ '[/@]T:(?<calendar>[a-z]{3,4})\\.' +
283
+ // Time components
284
+ '(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)' +
285
+ // Optional signature
286
+ '(?:!(?<signature>[^;?#]+))?' +
287
+ // Optional extensions
288
+ '(?:;(?<extensions>[a-z0-9.\\-_=]+))?' +
289
+ // Optional query params
290
+ '(?:\\?(?<params>[^#]+))?' +
291
+ // Optional context
292
+ '(?:#(?<context>.+))?$',
98
293
  );
99
294
 
100
295
  private static readonly REGEX_TIME = new RegExp(
101
- '^T:(?<calendar>[a-z]{3,4})\\.(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)?$',
296
+ '^T:(?<calendar>[a-z]{3,4})\\.' +
297
+ '(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)' +
298
+ '(?:!(?<signature>[^;?#]+))?$',
102
299
  );
103
300
 
104
301
  // --- CORE METHODS ---
@@ -125,23 +322,42 @@ export class TPS {
125
322
  * @returns Full URI string (e.g. "tps://...").
126
323
  */
127
324
  static toURI(comp: TPSComponents): string {
128
- // 1. Build Space Part
129
- let spacePart = 'unknown'; // Default safe fallback
325
+ // 1. Build Space Part (L: anchor)
326
+ let spacePart = 'L:-'; // Default: unknown
130
327
 
131
328
  if (comp.isHiddenLocation) {
132
- spacePart = 'hidden';
329
+ spacePart = 'L:~';
133
330
  } else if (comp.isRedactedLocation) {
134
- spacePart = 'redacted';
331
+ spacePart = 'L:redacted';
135
332
  } else if (comp.isUnknownLocation) {
136
- spacePart = 'unknown';
333
+ spacePart = 'L:-';
334
+ } else if (comp.s2Cell) {
335
+ spacePart = `L:s2=${comp.s2Cell}`;
336
+ } else if (comp.h3Cell) {
337
+ spacePart = `L:h3=${comp.h3Cell}`;
338
+ } else if (comp.plusCode) {
339
+ spacePart = `L:plus=${comp.plusCode}`;
340
+ } else if (comp.what3words) {
341
+ spacePart = `L:w3w=${comp.what3words}`;
342
+ } else if (comp.building) {
343
+ spacePart = `L:bldg=${comp.building}`;
344
+ if (comp.floor) spacePart += `.floor=${comp.floor}`;
345
+ if (comp.room) spacePart += `.room=${comp.room}`;
346
+ if (comp.zone) spacePart += `.zone=${comp.zone}`;
137
347
  } else if (comp.latitude !== undefined && comp.longitude !== undefined) {
138
- spacePart = `${comp.latitude},${comp.longitude}`;
348
+ spacePart = `L:${comp.latitude},${comp.longitude}`;
139
349
  if (comp.altitude !== undefined) {
140
350
  spacePart += `,${comp.altitude}m`;
141
351
  }
142
352
  }
143
353
 
144
- // 2. Build Time Part
354
+ // 2. Build Actor Part (A: anchor) - optional
355
+ let actorPart = '';
356
+ if (comp.actor) {
357
+ actorPart = `/A:${comp.actor}`;
358
+ }
359
+
360
+ // 3. Build Time Part
145
361
  let timePart = `T:${comp.calendar}`;
146
362
 
147
363
  if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
@@ -157,16 +373,21 @@ export class TPS {
157
373
  if (comp.second !== undefined) timePart += `.s${this.pad(comp.second)}`;
158
374
  }
159
375
 
160
- // 3. Build Extensions
376
+ // 4. Add Signature (!) - optional
377
+ if (comp.signature) {
378
+ timePart += `!${comp.signature}`;
379
+ }
380
+
381
+ // 5. Build Extensions
161
382
  let extPart = '';
162
383
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
163
384
  const extStrings = Object.entries(comp.extensions).map(
164
- ([k, v]) => `${k}${v}`,
385
+ ([k, v]) => `${k}=${v}`,
165
386
  );
166
387
  extPart = `;${extStrings.join('.')}`;
167
388
  }
168
389
 
169
- return `tps://${spacePart}@${timePart}${extPart}`;
390
+ return `tps://${spacePart}${actorPart}/${timePart}${extPart}`;
170
391
  }
171
392
 
172
393
  /**
@@ -203,7 +424,9 @@ export class TPS {
203
424
  const n = date.getUTCMinutes();
204
425
  const s = date.getUTCSeconds();
205
426
 
206
- return `T:greg.m${m}.c${c}.y${y}.M${this.pad(M)}.d${this.pad(d)}.h${this.pad(h)}.n${this.pad(n)}.s${this.pad(s)}`;
427
+ return `T:greg.m${m}.c${c}.y${y}.M${this.pad(M)}.d${this.pad(
428
+ d,
429
+ )}.h${this.pad(h)}.n${this.pad(n)}.s${this.pad(s)}`;
207
430
  }
208
431
 
209
432
  throw new Error(
@@ -268,6 +491,127 @@ export class TPS {
268
491
  return null;
269
492
  }
270
493
 
494
+ // --- DRIVER CONVENIENCE METHODS ---
495
+
496
+ /**
497
+ * Parse a calendar-specific date string into TPS components.
498
+ * Requires the driver to implement the optional `parseDate` method.
499
+ *
500
+ * @param calendar - The calendar code (e.g., 'hij')
501
+ * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
502
+ * @param format - Optional format string (driver-specific)
503
+ * @returns TPS components or null if parsing fails
504
+ *
505
+ * @example
506
+ * ```ts
507
+ * const components = TPS.parseCalendarDate('hij', '1447-07-21');
508
+ * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
509
+ *
510
+ * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
511
+ * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
512
+ * ```
513
+ */
514
+ static parseCalendarDate(
515
+ calendar: CalendarCode,
516
+ dateString: string,
517
+ format?: string,
518
+ ): Partial<TPSComponents> | null {
519
+ const driver = this.drivers.get(calendar);
520
+ if (!driver) {
521
+ throw new Error(
522
+ `Calendar driver '${calendar}' not found. Register a driver first.`,
523
+ );
524
+ }
525
+ if (!driver.parseDate) {
526
+ throw new Error(
527
+ `Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`,
528
+ );
529
+ }
530
+ return driver.parseDate(dateString, format);
531
+ }
532
+
533
+ /**
534
+ * Convert a calendar-specific date string directly to a TPS URI.
535
+ * This is a convenience method that combines parseDate + toURI.
536
+ *
537
+ * @param calendar - The calendar code (e.g., 'hij')
538
+ * @param dateString - Date string in calendar-native format
539
+ * @param location - Optional location (lat/lon/alt or privacy flag)
540
+ * @returns Full TPS URI string
541
+ *
542
+ * @example
543
+ * ```ts
544
+ * // With coordinates
545
+ * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
546
+ * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
547
+ *
548
+ * // With privacy flag
549
+ * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
550
+ * // "tps://hidden@T:hij.y1447.M07.d21"
551
+ *
552
+ * // Without location
553
+ * TPS.fromCalendarDate('hij', '1447-07-21');
554
+ * // "tps://unknown@T:hij.y1447.M07.d21"
555
+ * ```
556
+ */
557
+ static fromCalendarDate(
558
+ calendar: CalendarCode,
559
+ dateString: string,
560
+ location?: {
561
+ latitude?: number;
562
+ longitude?: number;
563
+ altitude?: number;
564
+ isUnknownLocation?: boolean;
565
+ isHiddenLocation?: boolean;
566
+ isRedactedLocation?: boolean;
567
+ },
568
+ ): string {
569
+ const components = this.parseCalendarDate(calendar, dateString);
570
+ if (!components) {
571
+ throw new Error(`Failed to parse date string: ${dateString}`);
572
+ }
573
+
574
+ // Merge with location
575
+ const fullComponents: TPSComponents = {
576
+ calendar,
577
+ ...components,
578
+ ...location,
579
+ } as TPSComponents;
580
+
581
+ return this.toURI(fullComponents);
582
+ }
583
+
584
+ /**
585
+ * Format TPS components to a calendar-specific date string.
586
+ * Requires the driver to implement the optional `format` method.
587
+ *
588
+ * @param calendar - The calendar code
589
+ * @param components - TPS components to format
590
+ * @param format - Optional format string (driver-specific)
591
+ * @returns Formatted date string in calendar-native format
592
+ *
593
+ * @example
594
+ * ```ts
595
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.M07.d21');
596
+ * const formatted = TPS.formatCalendarDate('hij', tps);
597
+ * // "1447-07-21"
598
+ * ```
599
+ */
600
+ static formatCalendarDate(
601
+ calendar: CalendarCode,
602
+ components: Partial<TPSComponents>,
603
+ format?: string,
604
+ ): string {
605
+ const driver = this.drivers.get(calendar);
606
+ if (!driver) {
607
+ throw new Error(`Calendar driver '${calendar}' not found.`);
608
+ }
609
+ if (!driver.format) {
610
+ throw new Error(`Driver '${calendar}' does not implement format().`);
611
+ }
612
+ return driver.format(components, format);
613
+ }
614
+
271
615
  // --- INTERNAL HELPERS ---
272
616
 
273
617
  private static _mapGroupsToComponents(
@@ -290,11 +634,44 @@ export class TPS {
290
634
  if (g.second) components.second = parseFloat(g.second);
291
635
  }
292
636
 
637
+ // Signature Mapping
638
+ if (g.signature) {
639
+ components.signature = g.signature;
640
+ }
641
+
642
+ // Actor Mapping
643
+ if (g.actor) {
644
+ components.actor = g.actor;
645
+ }
646
+
293
647
  // Space Mapping
294
648
  if (g.space) {
295
- if (g.space === 'unknown') components.isUnknownLocation = true;
296
- else if (g.space === 'redacted') components.isRedactedLocation = true;
297
- else if (g.space === 'hidden') components.isHiddenLocation = true;
649
+ // Privacy markers
650
+ if (g.space === 'unknown' || g.space === '-') {
651
+ components.isUnknownLocation = true;
652
+ } else if (g.space === 'redacted') {
653
+ components.isRedactedLocation = true;
654
+ } else if (g.space === 'hidden' || g.space === '~') {
655
+ components.isHiddenLocation = true;
656
+ }
657
+ // Geospatial cells
658
+ else if (g.s2) {
659
+ components.s2Cell = g.s2;
660
+ } else if (g.h3) {
661
+ components.h3Cell = g.h3;
662
+ } else if (g.plus) {
663
+ components.plusCode = g.plus;
664
+ } else if (g.w3w) {
665
+ components.what3words = g.w3w;
666
+ }
667
+ // Structural anchors
668
+ else if (g.bldg) {
669
+ components.building = g.bldg;
670
+ if (g.floor) components.floor = g.floor;
671
+ if (g.room) components.room = g.room;
672
+ if (g.zone) components.zone = g.zone;
673
+ }
674
+ // GPS coordinates
298
675
  else {
299
676
  if (g.lat) components.latitude = parseFloat(g.lat);
300
677
  if (g.lon) components.longitude = parseFloat(g.lon);
@@ -307,9 +684,17 @@ export class TPS {
307
684
  const extObj: any = {};
308
685
  const parts = g.extensions.split('.');
309
686
  parts.forEach((p: string) => {
310
- const key = p.charAt(0);
311
- const val = p.substring(1);
312
- if (key && val) extObj[key] = val;
687
+ const eqIdx = p.indexOf('=');
688
+ if (eqIdx > 0) {
689
+ const key = p.substring(0, eqIdx);
690
+ const val = p.substring(eqIdx + 1);
691
+ if (key && val) extObj[key] = val;
692
+ } else {
693
+ // Legacy format: first char is key
694
+ const key = p.charAt(0);
695
+ const val = p.substring(1);
696
+ if (key && val) extObj[key] = val;
697
+ }
313
698
  });
314
699
  components.extensions = extObj;
315
700
  }
@@ -322,3 +707,791 @@ export class TPS {
322
707
  return s.length < 2 ? '0' + s : s;
323
708
  }
324
709
  }
710
+
711
+ // --- TPS-UID v1 Types ---
712
+
713
+ /**
714
+ * Decoded result from TPSUID7RB binary format.
715
+ */
716
+ export type TPSUID7RBDecodeResult = {
717
+ /** Version identifier */
718
+ version: 'tpsuid7rb';
719
+ /** Epoch milliseconds (UTC) */
720
+ epochMs: number;
721
+ /** Whether the TPS payload was compressed */
722
+ compressed: boolean;
723
+ /** 32-bit nonce for collision prevention */
724
+ nonce: number;
725
+ /** The original TPS string (exact reconstruction) */
726
+ tps: string;
727
+ };
728
+
729
+ /**
730
+ * Encoding options for TPSUID7RB.
731
+ */
732
+ export type TPSUID7RBEncodeOptions = {
733
+ /** Enable zlib compression of TPS payload */
734
+ compress?: boolean;
735
+ /** Override epoch milliseconds (default: parsed from TPS) */
736
+ epochMs?: number;
737
+ };
738
+
739
+ /**
740
+ * TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
741
+ *
742
+ * A time-first, reversible identifier that binds an event to a TPS coordinate.
743
+ * Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
744
+ * reconstruction of the original TPS string.
745
+ *
746
+ * Binary Schema (all integers big-endian):
747
+ * ```
748
+ * MAGIC 4 bytes "TPU7"
749
+ * VER 1 byte 0x01
750
+ * FLAGS 1 byte bit0 = compression flag
751
+ * TIME 6 bytes epoch_ms (48-bit unsigned)
752
+ * NONCE 4 bytes 32-bit random
753
+ * LEN varint length of TPS payload
754
+ * TPS bytes UTF-8 TPS string (raw or zlib-compressed)
755
+ * ```
756
+ *
757
+ * @example
758
+ * ```ts
759
+ * const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.M01.d09';
760
+ *
761
+ * // Encode to binary
762
+ * const bytes = TPSUID7RB.encodeBinary(tps);
763
+ *
764
+ * // Encode to base64url string
765
+ * const id = TPSUID7RB.encodeBinaryB64(tps);
766
+ * // → "tpsuid7rb_AFRQV..."
767
+ *
768
+ * // Decode back to original TPS
769
+ * const decoded = TPSUID7RB.decodeBinaryB64(id);
770
+ * console.log(decoded.tps); // exact original TPS
771
+ * ```
772
+ */
773
+ export class TPSUID7RB {
774
+ /** Magic bytes: "TPU7" */
775
+ private static readonly MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
776
+ /** Version 1 */
777
+ private static readonly VER = 0x01;
778
+ /** String prefix for base64url encoded form */
779
+ private static readonly PREFIX = 'tpsuid7rb_';
780
+ /** Regex for validating base64url encoded form */
781
+ public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
782
+
783
+ // ---------------------------
784
+ // Public API
785
+ // ---------------------------
786
+
787
+ /**
788
+ * Encode TPS string to binary bytes (Uint8Array).
789
+ * This is the canonical form for hashing, signing, and storage.
790
+ *
791
+ * @param tps - The TPS string to encode
792
+ * @param opts - Encoding options (compress, epochMs override)
793
+ * @returns Binary TPS-UID as Uint8Array
794
+ */
795
+ static encodeBinary(tps: string, opts?: TPSUID7RBEncodeOptions): Uint8Array {
796
+ const compress = opts?.compress ?? false;
797
+ const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
798
+
799
+ if (!Number.isInteger(epochMs) || epochMs < 0) {
800
+ throw new Error('epochMs must be a non-negative integer');
801
+ }
802
+ if (epochMs > 0xffffffffffff) {
803
+ throw new Error('epochMs exceeds 48-bit range');
804
+ }
805
+
806
+ const flags = compress ? 0x01 : 0x00;
807
+
808
+ // Generate 32-bit nonce
809
+ const nonceBuf = this.randomBytes(4);
810
+ const nonce =
811
+ ((nonceBuf[0] << 24) >>> 0) +
812
+ ((nonceBuf[1] << 16) >>> 0) +
813
+ ((nonceBuf[2] << 8) >>> 0) +
814
+ nonceBuf[3];
815
+
816
+ // Encode TPS to UTF-8
817
+ const tpsUtf8 = new TextEncoder().encode(tps);
818
+
819
+ // Optionally compress
820
+ const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
821
+
822
+ // Encode length as varint
823
+ const lenVar = this.uvarintEncode(payload.length);
824
+
825
+ // Construct binary structure
826
+ const out = new Uint8Array(
827
+ 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length,
828
+ );
829
+ let offset = 0;
830
+
831
+ // MAGIC
832
+ out.set(this.MAGIC, offset);
833
+ offset += 4;
834
+
835
+ // VER
836
+ out[offset++] = this.VER;
837
+
838
+ // FLAGS
839
+ out[offset++] = flags;
840
+
841
+ // TIME (48-bit big-endian)
842
+ const timeBytes = this.writeU48(epochMs);
843
+ out.set(timeBytes, offset);
844
+ offset += 6;
845
+
846
+ // NONCE (32-bit big-endian)
847
+ out.set(nonceBuf, offset);
848
+ offset += 4;
849
+
850
+ // LEN (varint)
851
+ out.set(lenVar, offset);
852
+ offset += lenVar.length;
853
+
854
+ // TPS payload
855
+ out.set(payload, offset);
856
+
857
+ return out;
858
+ }
859
+
860
+ /**
861
+ * Decode binary bytes back to original TPS string.
862
+ *
863
+ * @param bytes - Binary TPS-UID
864
+ * @returns Decoded result with original TPS string
865
+ */
866
+ static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
867
+ // Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
868
+ if (bytes.length < 17) {
869
+ throw new Error('TPSUID7RB: too short');
870
+ }
871
+
872
+ // MAGIC
873
+ if (
874
+ bytes[0] !== 0x54 ||
875
+ bytes[1] !== 0x50 ||
876
+ bytes[2] !== 0x55 ||
877
+ bytes[3] !== 0x37
878
+ ) {
879
+ throw new Error('TPSUID7RB: bad magic');
880
+ }
881
+
882
+ // VERSION
883
+ const ver = bytes[4];
884
+ if (ver !== this.VER) {
885
+ throw new Error(`TPSUID7RB: unsupported version ${ver}`);
886
+ }
887
+
888
+ // FLAGS
889
+ const flags = bytes[5];
890
+ const compressed = (flags & 0x01) === 0x01;
891
+
892
+ // TIME (48-bit big-endian)
893
+ const epochMs = this.readU48(bytes, 6);
894
+
895
+ // NONCE (32-bit big-endian)
896
+ const nonce =
897
+ ((bytes[12] << 24) >>> 0) +
898
+ ((bytes[13] << 16) >>> 0) +
899
+ ((bytes[14] << 8) >>> 0) +
900
+ bytes[15];
901
+
902
+ // LEN (varint at offset 16)
903
+ let offset = 16;
904
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
905
+ offset += bytesRead;
906
+
907
+ if (offset + tpsLen > bytes.length) {
908
+ throw new Error('TPSUID7RB: length overflow');
909
+ }
910
+
911
+ // TPS payload
912
+ const payload = bytes.slice(offset, offset + tpsLen);
913
+ const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
914
+ const tps = new TextDecoder().decode(tpsUtf8);
915
+
916
+ return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
917
+ }
918
+
919
+ /**
920
+ * Encode TPS to base64url string with prefix.
921
+ * This is the transport/storage form.
922
+ *
923
+ * @param tps - The TPS string to encode
924
+ * @param opts - Encoding options
925
+ * @returns Base64url encoded TPS-UID with prefix
926
+ */
927
+ static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
928
+ const bytes = this.encodeBinary(tps, opts);
929
+ return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
930
+ }
931
+
932
+ /**
933
+ * Decode base64url string back to original TPS string.
934
+ *
935
+ * @param id - Base64url encoded TPS-UID with prefix
936
+ * @returns Decoded result with original TPS string
937
+ */
938
+ static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
939
+ const s = id.trim();
940
+ if (!s.startsWith(this.PREFIX)) {
941
+ throw new Error('TPSUID7RB: missing prefix');
942
+ }
943
+ const b64 = s.slice(this.PREFIX.length);
944
+ const bytes = this.base64UrlDecode(b64);
945
+ return this.decodeBinary(bytes);
946
+ }
947
+
948
+ /**
949
+ * Validate base64url encoded TPS-UID format.
950
+ * Note: This validates shape only; binary decode is authoritative.
951
+ *
952
+ * @param id - String to validate
953
+ * @returns true if format is valid
954
+ */
955
+ static validateBinaryB64(id: string): boolean {
956
+ return this.REGEX.test(id.trim());
957
+ }
958
+
959
+ /**
960
+ * Generate a TPS-UID from the current time and optional location.
961
+ *
962
+ * @param opts - Generation options
963
+ * @returns Base64url encoded TPS-UID
964
+ */
965
+ static generate(opts?: {
966
+ latitude?: number;
967
+ longitude?: number;
968
+ altitude?: number;
969
+ compress?: boolean;
970
+ }): string {
971
+ const now = new Date();
972
+ const tps = this.generateTPSString(now, opts);
973
+ return this.encodeBinaryB64(tps, {
974
+ compress: opts?.compress,
975
+ epochMs: now.getTime(),
976
+ });
977
+ }
978
+
979
+ // ---------------------------
980
+ // TPS String Helpers
981
+ // ---------------------------
982
+
983
+ /**
984
+ * Generate a TPS string from a Date and optional location.
985
+ */
986
+ private static generateTPSString(
987
+ date: Date,
988
+ opts?: { latitude?: number; longitude?: number; altitude?: number },
989
+ ): string {
990
+ const fullYear = date.getUTCFullYear();
991
+ const m = Math.floor(fullYear / 1000) + 1;
992
+ const c = Math.floor((fullYear % 1000) / 100) + 1;
993
+ const y = fullYear % 100;
994
+ const M = date.getUTCMonth() + 1;
995
+ const d = date.getUTCDate();
996
+ const h = date.getUTCHours();
997
+ const n = date.getUTCMinutes();
998
+ const s = date.getUTCSeconds();
999
+
1000
+ const pad = (num: number) => num.toString().padStart(2, '0');
1001
+
1002
+ const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(
1003
+ h,
1004
+ )}.n${pad(n)}.s${pad(s)}`;
1005
+
1006
+ let spacePart = 'unknown';
1007
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
1008
+ spacePart = `${opts.latitude},${opts.longitude}`;
1009
+ if (opts.altitude !== undefined) {
1010
+ spacePart += `,${opts.altitude}m`;
1011
+ }
1012
+ }
1013
+
1014
+ return `tps://${spacePart}@${timePart}`;
1015
+ }
1016
+
1017
+ /**
1018
+ * Parse epoch milliseconds from a TPS string.
1019
+ * Supports both URI format (tps://...) and time-only format (T:greg...)
1020
+ */
1021
+ static epochMsFromTPSString(tps: string): number {
1022
+ let time: string;
1023
+
1024
+ if (tps.includes('@')) {
1025
+ // URI format: tps://...@T:greg...
1026
+ const at = tps.indexOf('@');
1027
+ time = tps.slice(at + 1).trim();
1028
+ } else if (tps.startsWith('T:')) {
1029
+ // Time-only format
1030
+ time = tps;
1031
+ } else {
1032
+ throw new Error('TPS: unrecognized format');
1033
+ }
1034
+
1035
+ if (!time.startsWith('T:greg.')) {
1036
+ throw new Error('TPS: only T:greg.* parsing is supported');
1037
+ }
1038
+
1039
+ // Extract m (millennium), c (century), y (year)
1040
+ const mMatch = time.match(/\.m(-?\d+)/);
1041
+ const cMatch = time.match(/\.c(\d+)/);
1042
+ const yMatch = time.match(/\.y(\d{1,4})/);
1043
+ const MMatch = time.match(/\.M(\d{1,2})/);
1044
+ const dMatch = time.match(/\.d(\d{1,2})/);
1045
+ const hMatch = time.match(/\.h(\d{1,2})/);
1046
+ const nMatch = time.match(/\.n(\d{1,2})/);
1047
+ const sMatch = time.match(/\.s(\d{1,2})/);
1048
+
1049
+ // Calculate full year from millennium, century, year
1050
+ let fullYear: number;
1051
+ if (mMatch && cMatch && yMatch) {
1052
+ const millennium = parseInt(mMatch[1], 10);
1053
+ const century = parseInt(cMatch[1], 10);
1054
+ const year = parseInt(yMatch[1], 10);
1055
+ fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
1056
+ } else if (yMatch) {
1057
+ // Fallback: interpret y as 2-digit year
1058
+ let year = parseInt(yMatch[1], 10);
1059
+ if (year < 100) {
1060
+ year = year <= 69 ? 2000 + year : 1900 + year;
1061
+ }
1062
+ fullYear = year;
1063
+ } else {
1064
+ throw new Error('TPS: missing year component');
1065
+ }
1066
+
1067
+ const month = MMatch ? parseInt(MMatch[1], 10) : 1;
1068
+ const day = dMatch ? parseInt(dMatch[1], 10) : 1;
1069
+ const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
1070
+ const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
1071
+ const second = sMatch ? parseInt(sMatch[1], 10) : 0;
1072
+
1073
+ const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
1074
+ if (!Number.isFinite(epoch)) {
1075
+ throw new Error('TPS: failed to compute epochMs');
1076
+ }
1077
+
1078
+ return epoch;
1079
+ }
1080
+
1081
+ // ---------------------------
1082
+ // Binary Helpers
1083
+ // ---------------------------
1084
+
1085
+ /** Write 48-bit unsigned integer (big-endian) */
1086
+ private static writeU48(epochMs: number): Uint8Array {
1087
+ const b = new Uint8Array(6);
1088
+ // Use BigInt for proper 48-bit handling
1089
+ const v = BigInt(epochMs);
1090
+ b[0] = Number((v >> 40n) & 0xffn);
1091
+ b[1] = Number((v >> 32n) & 0xffn);
1092
+ b[2] = Number((v >> 24n) & 0xffn);
1093
+ b[3] = Number((v >> 16n) & 0xffn);
1094
+ b[4] = Number((v >> 8n) & 0xffn);
1095
+ b[5] = Number(v & 0xffn);
1096
+ return b;
1097
+ }
1098
+
1099
+ /** Read 48-bit unsigned integer (big-endian) */
1100
+ private static readU48(bytes: Uint8Array, offset: number): number {
1101
+ const v =
1102
+ (BigInt(bytes[offset]) << 40n) +
1103
+ (BigInt(bytes[offset + 1]) << 32n) +
1104
+ (BigInt(bytes[offset + 2]) << 24n) +
1105
+ (BigInt(bytes[offset + 3]) << 16n) +
1106
+ (BigInt(bytes[offset + 4]) << 8n) +
1107
+ BigInt(bytes[offset + 5]);
1108
+
1109
+ const n = Number(v);
1110
+ if (!Number.isSafeInteger(n)) {
1111
+ throw new Error('TPSUID7RB: u48 not safe integer');
1112
+ }
1113
+ return n;
1114
+ }
1115
+
1116
+ /** Encode unsigned integer as LEB128 varint */
1117
+ private static uvarintEncode(n: number): Uint8Array {
1118
+ if (!Number.isInteger(n) || n < 0) {
1119
+ throw new Error('uvarint must be non-negative int');
1120
+ }
1121
+ const out: number[] = [];
1122
+ let x = n >>> 0;
1123
+ while (x >= 0x80) {
1124
+ out.push((x & 0x7f) | 0x80);
1125
+ x >>>= 7;
1126
+ }
1127
+ out.push(x);
1128
+ return new Uint8Array(out);
1129
+ }
1130
+
1131
+ /** Decode LEB128 varint */
1132
+ private static uvarintDecode(
1133
+ bytes: Uint8Array,
1134
+ offset: number,
1135
+ ): { value: number; bytesRead: number } {
1136
+ let x = 0;
1137
+ let s = 0;
1138
+ let i = 0;
1139
+ while (true) {
1140
+ if (offset + i >= bytes.length) {
1141
+ throw new Error('uvarint overflow');
1142
+ }
1143
+ const b = bytes[offset + i];
1144
+ if (b < 0x80) {
1145
+ if (i > 9 || (i === 9 && b > 1)) {
1146
+ throw new Error('uvarint too large');
1147
+ }
1148
+ x |= b << s;
1149
+ return { value: x >>> 0, bytesRead: i + 1 };
1150
+ }
1151
+ x |= (b & 0x7f) << s;
1152
+ s += 7;
1153
+ i++;
1154
+ if (i > 10) {
1155
+ throw new Error('uvarint too long');
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ // ---------------------------
1161
+ // Base64url Helpers
1162
+ // ---------------------------
1163
+
1164
+ /** Encode bytes to base64url (no padding) */
1165
+ private static base64UrlEncode(bytes: Uint8Array): string {
1166
+ // Node.js environment
1167
+ if (typeof Buffer !== 'undefined') {
1168
+ return Buffer.from(bytes)
1169
+ .toString('base64')
1170
+ .replace(/\+/g, '-')
1171
+ .replace(/\//g, '_')
1172
+ .replace(/=+$/g, '');
1173
+ }
1174
+ // Browser environment
1175
+ let binary = '';
1176
+ for (let i = 0; i < bytes.length; i++) {
1177
+ binary += String.fromCharCode(bytes[i]);
1178
+ }
1179
+ return btoa(binary)
1180
+ .replace(/\+/g, '-')
1181
+ .replace(/\//g, '_')
1182
+ .replace(/=+$/g, '');
1183
+ }
1184
+
1185
+ /** Decode base64url to bytes */
1186
+ private static base64UrlDecode(b64url: string): Uint8Array {
1187
+ // Add padding
1188
+ const padLen = (4 - (b64url.length % 4)) % 4;
1189
+ const b64 = (b64url + '='.repeat(padLen))
1190
+ .replace(/-/g, '+')
1191
+ .replace(/_/g, '/');
1192
+
1193
+ // Node.js environment
1194
+ if (typeof Buffer !== 'undefined') {
1195
+ return new Uint8Array(Buffer.from(b64, 'base64'));
1196
+ }
1197
+ // Browser environment
1198
+ const binary = atob(b64);
1199
+ const bytes = new Uint8Array(binary.length);
1200
+ for (let i = 0; i < binary.length; i++) {
1201
+ bytes[i] = binary.charCodeAt(i);
1202
+ }
1203
+ return bytes;
1204
+ }
1205
+
1206
+ // ---------------------------
1207
+ // Compression Helpers
1208
+ // ---------------------------
1209
+
1210
+ /** Compress using zlib deflate raw */
1211
+ private static deflateRaw(data: Uint8Array): Uint8Array {
1212
+ // Node.js environment
1213
+ if (typeof require !== 'undefined') {
1214
+ try {
1215
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1216
+ const zlib = require('zlib');
1217
+ return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
1218
+ } catch {
1219
+ throw new Error('TPSUID7RB: compression not available');
1220
+ }
1221
+ }
1222
+ // Browser: would need pako or similar library
1223
+ throw new Error('TPSUID7RB: compression not available in browser');
1224
+ }
1225
+
1226
+ /** Decompress using zlib inflate raw */
1227
+ private static inflateRaw(data: Uint8Array): Uint8Array {
1228
+ // Node.js environment
1229
+ if (typeof require !== 'undefined') {
1230
+ try {
1231
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1232
+ const zlib = require('zlib');
1233
+ return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
1234
+ } catch {
1235
+ throw new Error('TPSUID7RB: decompression failed');
1236
+ }
1237
+ }
1238
+ // Browser: would need pako or similar library
1239
+ throw new Error('TPSUID7RB: decompression not available in browser');
1240
+ }
1241
+
1242
+ // ---------------------------
1243
+ // Cryptographic Sealing (Ed25519)
1244
+ // ---------------------------
1245
+
1246
+ /**
1247
+ * Seal (sign) a TPS string to create a cryptographically verifiable TPS-UID.
1248
+ * This appends an Ed25519 signature to the binary form.
1249
+ *
1250
+ * @param tps - The TPS string to seal
1251
+ * @param privateKey - Ed25519 private key (hex or buffer)
1252
+ * @param opts - Encoding options
1253
+ * @returns Sealed binary TPS-UID
1254
+ */
1255
+ static seal(
1256
+ tps: string,
1257
+ privateKey: string | Buffer | Uint8Array,
1258
+ opts?: TPSUID7RBEncodeOptions,
1259
+ ): Uint8Array {
1260
+ // 1. Create standard binary (unsealed first)
1261
+ // We force the SEAL flag (bit 1) to be 0 initially for the "content to sign"
1262
+ // But wait, we want the signature to cover the header too.
1263
+ // Strategy: Construct the full binary with SEAL flag OFF, sign it, then set SEAL flag ON and append sig.
1264
+ // Actually, the standard way is:
1265
+ // Content = MAGIC + VER + FLAGS(with seal bit set) + TIME + NONCE + LEN + PAYLOAD
1266
+ // Signature = Sign(Content)
1267
+ // Final = Content + SEAL_TYPE + SIGNATURE
1268
+
1269
+ const compress = opts?.compress ?? false;
1270
+ const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
1271
+
1272
+ // Validate epoch
1273
+ if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
1274
+ throw new Error('epochMs must be a valid 48-bit non-negative integer');
1275
+ }
1276
+
1277
+ // Flags: Bit 0 = compress, Bit 1 = sealed
1278
+ const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
1279
+
1280
+ // Generate Nonce
1281
+ const nonceBuf = this.randomBytes(4);
1282
+
1283
+ // Encode Payload
1284
+ const tpsUtf8 = new TextEncoder().encode(tps);
1285
+ const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
1286
+ const lenVar = this.uvarintEncode(payload.length);
1287
+
1288
+ // Construct Content (Header + Payload)
1289
+ const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
1290
+ const content = new Uint8Array(contentLen);
1291
+ let offset = 0;
1292
+
1293
+ content.set(this.MAGIC, offset);
1294
+ offset += 4;
1295
+ content[offset++] = this.VER;
1296
+ content[offset++] = flags;
1297
+ content.set(this.writeU48(epochMs), offset);
1298
+ offset += 6;
1299
+ content.set(nonceBuf, offset);
1300
+ offset += 4;
1301
+ content.set(lenVar, offset);
1302
+ offset += lenVar.length;
1303
+ content.set(payload, offset);
1304
+
1305
+ // Sign the content
1306
+ const signature = this.signEd25519(content, privateKey);
1307
+ const sealType = 0x01; // Ed25519
1308
+
1309
+ // Final Output: Content + SealType (1) + Signature (64)
1310
+ const final = new Uint8Array(contentLen + 1 + signature.length);
1311
+ final.set(content, 0);
1312
+ final.set([sealType], contentLen);
1313
+ final.set(signature, contentLen + 1);
1314
+
1315
+ return final;
1316
+ }
1317
+
1318
+ /**
1319
+ * Verify a sealed TPS-UID and decode it.
1320
+ * Throws if signature is invalid or not sealed.
1321
+ *
1322
+ * @param sealedBytes - The binary sealed TPS-UID
1323
+ * @param publicKey - Ed25519 public key (hex or buffer) to verify against
1324
+ * @returns Decoded result
1325
+ */
1326
+ static verifyAndDecode(
1327
+ sealedBytes: Uint8Array,
1328
+ publicKey: string | Buffer | Uint8Array,
1329
+ ): TPSUID7RBDecodeResult {
1330
+ if (sealedBytes.length < 18) throw new Error('TPSUID7RB: too short');
1331
+
1332
+ // Check Magic
1333
+ if (
1334
+ sealedBytes[0] !== 0x54 ||
1335
+ sealedBytes[1] !== 0x50 ||
1336
+ sealedBytes[2] !== 0x55 ||
1337
+ sealedBytes[3] !== 0x37
1338
+ ) {
1339
+ throw new Error('TPSUID7RB: bad magic');
1340
+ }
1341
+
1342
+ // Check Flags for Sealed Bit (bit 1)
1343
+ const flags = sealedBytes[5];
1344
+ if ((flags & 0x02) === 0) {
1345
+ throw new Error('TPSUID7RB: not a sealed UID');
1346
+ }
1347
+
1348
+ // 1. Parse the structure to find where content ends
1349
+ // We need to parse LEN and Payload to find the split point
1350
+ let offset = 16; // Start of LEN
1351
+ // Decode LEN
1352
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(
1353
+ sealedBytes,
1354
+ offset,
1355
+ );
1356
+ offset += bytesRead;
1357
+ const payloadEnd = offset + tpsLen;
1358
+
1359
+ if (payloadEnd > sealedBytes.length) {
1360
+ throw new Error('TPSUID7RB: length overflow (truncated)');
1361
+ }
1362
+
1363
+ // The Content to verify matches exactly [0 ... payloadEnd]
1364
+ const content = sealedBytes.slice(0, payloadEnd);
1365
+
1366
+ // After content: SealType (1 byte) + Signature
1367
+ if (sealedBytes.length <= payloadEnd + 1) {
1368
+ throw new Error('TPSUID7RB: missing signature data');
1369
+ }
1370
+
1371
+ const sealType = sealedBytes[payloadEnd];
1372
+ if (sealType !== 0x01) {
1373
+ throw new Error(
1374
+ `TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`,
1375
+ );
1376
+ }
1377
+
1378
+ const signature = sealedBytes.slice(payloadEnd + 1);
1379
+ if (signature.length !== 64) {
1380
+ throw new Error(
1381
+ `TPSUID7RB: invalid Ed25519 signature length ${signature.length}`,
1382
+ );
1383
+ }
1384
+
1385
+ // Verify
1386
+ const isValid = this.verifyEd25519(content, signature, publicKey);
1387
+ if (!isValid) {
1388
+ throw new Error('TPSUID7RB: signature verification failed');
1389
+ }
1390
+
1391
+ // Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
1392
+ // Actually standard logic doesn't expect trailing bytes unless we tell it to.
1393
+ // But since we verified, we can just slice the content and decode that as a strict binary
1394
+ // EXCEPT standard decodeBinary checks strict length.
1395
+ // So we manually decode the components here to be safe and efficient.
1396
+
1397
+ return this.decodeBinary(content); // Reuse strict decoder on the content part
1398
+ }
1399
+
1400
+ // --- Crypto Implementation (Ed25519) ---
1401
+
1402
+ private static signEd25519(
1403
+ data: Uint8Array,
1404
+ privateKey: string | Buffer | Uint8Array,
1405
+ ): Uint8Array {
1406
+ if (typeof require !== 'undefined') {
1407
+ try {
1408
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1409
+ const crypto = require('crypto');
1410
+ // Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
1411
+ // or ensure key is properly formatted.
1412
+ // For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
1413
+ // If raw bytes: establish KeyObject.
1414
+
1415
+ let keyObj;
1416
+ if (Buffer.isBuffer(privateKey) || privateKey instanceof Uint8Array) {
1417
+ // Assuming raw 64-byte private key (or 32-byte seed properly expanded by crypto)
1418
+ // Node < 16 is tricky with raw keys.
1419
+ // Let's assume standard Ed25519 standard implementation pattern logic:
1420
+ keyObj = crypto.createPrivateKey({
1421
+ key: Buffer.from(privateKey),
1422
+ format: 'der', // or 'pem' - strict.
1423
+ type: 'pkcs8',
1424
+ });
1425
+ // Actually, simpler: construct key object from raw bytes if possible?
1426
+ // Node's crypto is strict. Let's try the simplest:
1427
+ // If hex string provided, convert to buffer.
1428
+ }
1429
+
1430
+ // Simpler fallback: If user passed a PEM string, great.
1431
+ // If they passed raw bytes, we might need 'ed25519' key type.
1432
+ // For this implementation, let's target Node's high-level sign/verify
1433
+ // and assume the user provides a VALID key object or compatible format (PEM/DER).
1434
+ // Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
1435
+
1436
+ const key =
1437
+ typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
1438
+ ? crypto.createPrivateKey({
1439
+ key: Buffer.from(privateKey, 'hex'),
1440
+ format: 'pem',
1441
+ type: 'pkcs8',
1442
+ }) // Fallback guess
1443
+ : privateKey;
1444
+
1445
+ // Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
1446
+ // Direct raw signing is via crypto.sign(null, data, key).
1447
+ return new Uint8Array(crypto.sign(null, data, key));
1448
+ } catch (e) {
1449
+ // If standard crypto fails (e.g. key format issue), throw
1450
+ throw new Error('TPSUID7RB: signing failed (check key format)');
1451
+ }
1452
+ }
1453
+ throw new Error('TPSUID7RB: signing not available in browser');
1454
+ }
1455
+
1456
+ private static verifyEd25519(
1457
+ data: Uint8Array,
1458
+ signature: Uint8Array,
1459
+ publicKey: string | Buffer | Uint8Array,
1460
+ ): boolean {
1461
+ if (typeof require !== 'undefined') {
1462
+ try {
1463
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1464
+ const crypto = require('crypto');
1465
+ return crypto.verify(null, data, publicKey, signature);
1466
+ } catch {
1467
+ return false;
1468
+ }
1469
+ }
1470
+ throw new Error('TPSUID7RB: verification not available in browser');
1471
+ }
1472
+
1473
+ // ---------------------------
1474
+ // Random Bytes
1475
+ // ---------------------------
1476
+
1477
+ /** Generate cryptographically secure random bytes */
1478
+ private static randomBytes(length: number): Uint8Array {
1479
+ // Node.js environment
1480
+ if (typeof require !== 'undefined') {
1481
+ try {
1482
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1483
+ const crypto = require('crypto');
1484
+ return new Uint8Array(crypto.randomBytes(length));
1485
+ } catch {
1486
+ // Fallback to crypto.getRandomValues
1487
+ }
1488
+ }
1489
+ // Browser or fallback
1490
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1491
+ const bytes = new Uint8Array(length);
1492
+ crypto.getRandomValues(bytes);
1493
+ return bytes;
1494
+ }
1495
+ throw new Error('TPSUID7RB: no crypto available');
1496
+ }
1497
+ }