@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/dist/index.js CHANGED
@@ -3,12 +3,18 @@
3
3
  * TPS: Temporal Positioning System
4
4
  * The Universal Protocol for Space-Time Coordinates.
5
5
  * @packageDocumentation
6
- * @version 0.4.2
7
- * @license MIT
6
+ * @version 0.5.0
7
+ * @license Apache-2.0
8
8
  * @copyright 2026 TPS Standards Working Group
9
+ *
10
+ * v0.5.0 Changes:
11
+ * - Added Actor anchor (A:) for provenance tracking
12
+ * - Added Signature (!) for cryptographic verification
13
+ * - Added structural anchors (bldg, floor, room, zone)
14
+ * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
9
15
  */
10
16
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.TPS = void 0;
17
+ exports.TPSUID7RB = exports.TPS = void 0;
12
18
  class TPS {
13
19
  /**
14
20
  * Registers a calendar driver plugin.
@@ -49,24 +55,50 @@ class TPS {
49
55
  * @returns Full URI string (e.g. "tps://...").
50
56
  */
51
57
  static toURI(comp) {
52
- // 1. Build Space Part
53
- let spacePart = 'unknown'; // Default safe fallback
58
+ // 1. Build Space Part (L: anchor)
59
+ let spacePart = 'L:-'; // Default: unknown
54
60
  if (comp.isHiddenLocation) {
55
- spacePart = 'hidden';
61
+ spacePart = 'L:~';
56
62
  }
57
63
  else if (comp.isRedactedLocation) {
58
- spacePart = 'redacted';
64
+ spacePart = 'L:redacted';
59
65
  }
60
66
  else if (comp.isUnknownLocation) {
61
- spacePart = 'unknown';
67
+ spacePart = 'L:-';
68
+ }
69
+ else if (comp.s2Cell) {
70
+ spacePart = `L:s2=${comp.s2Cell}`;
71
+ }
72
+ else if (comp.h3Cell) {
73
+ spacePart = `L:h3=${comp.h3Cell}`;
74
+ }
75
+ else if (comp.plusCode) {
76
+ spacePart = `L:plus=${comp.plusCode}`;
77
+ }
78
+ else if (comp.what3words) {
79
+ spacePart = `L:w3w=${comp.what3words}`;
80
+ }
81
+ else if (comp.building) {
82
+ spacePart = `L:bldg=${comp.building}`;
83
+ if (comp.floor)
84
+ spacePart += `.floor=${comp.floor}`;
85
+ if (comp.room)
86
+ spacePart += `.room=${comp.room}`;
87
+ if (comp.zone)
88
+ spacePart += `.zone=${comp.zone}`;
62
89
  }
63
90
  else if (comp.latitude !== undefined && comp.longitude !== undefined) {
64
- spacePart = `${comp.latitude},${comp.longitude}`;
91
+ spacePart = `L:${comp.latitude},${comp.longitude}`;
65
92
  if (comp.altitude !== undefined) {
66
93
  spacePart += `,${comp.altitude}m`;
67
94
  }
68
95
  }
69
- // 2. Build Time Part
96
+ // 2. Build Actor Part (A: anchor) - optional
97
+ let actorPart = '';
98
+ if (comp.actor) {
99
+ actorPart = `/A:${comp.actor}`;
100
+ }
101
+ // 3. Build Time Part
70
102
  let timePart = `T:${comp.calendar}`;
71
103
  if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
72
104
  timePart += `.s${comp.unixSeconds}`;
@@ -89,13 +121,17 @@ class TPS {
89
121
  if (comp.second !== undefined)
90
122
  timePart += `.s${this.pad(comp.second)}`;
91
123
  }
92
- // 3. Build Extensions
124
+ // 4. Add Signature (!) - optional
125
+ if (comp.signature) {
126
+ timePart += `!${comp.signature}`;
127
+ }
128
+ // 5. Build Extensions
93
129
  let extPart = '';
94
130
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
95
- const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}${v}`);
131
+ const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}=${v}`);
96
132
  extPart = `;${extStrings.join('.')}`;
97
133
  }
98
- return `tps://${spacePart}@${timePart}${extPart}`;
134
+ return `tps://${spacePart}${actorPart}/${timePart}${extPart}`;
99
135
  }
100
136
  /**
101
137
  * CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
@@ -172,6 +208,98 @@ class TPS {
172
208
  }
173
209
  return null;
174
210
  }
211
+ // --- DRIVER CONVENIENCE METHODS ---
212
+ /**
213
+ * Parse a calendar-specific date string into TPS components.
214
+ * Requires the driver to implement the optional `parseDate` method.
215
+ *
216
+ * @param calendar - The calendar code (e.g., 'hij')
217
+ * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
218
+ * @param format - Optional format string (driver-specific)
219
+ * @returns TPS components or null if parsing fails
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const components = TPS.parseCalendarDate('hij', '1447-07-21');
224
+ * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
225
+ *
226
+ * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
227
+ * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
228
+ * ```
229
+ */
230
+ static parseCalendarDate(calendar, dateString, format) {
231
+ const driver = this.drivers.get(calendar);
232
+ if (!driver) {
233
+ throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
234
+ }
235
+ if (!driver.parseDate) {
236
+ throw new Error(`Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`);
237
+ }
238
+ return driver.parseDate(dateString, format);
239
+ }
240
+ /**
241
+ * Convert a calendar-specific date string directly to a TPS URI.
242
+ * This is a convenience method that combines parseDate + toURI.
243
+ *
244
+ * @param calendar - The calendar code (e.g., 'hij')
245
+ * @param dateString - Date string in calendar-native format
246
+ * @param location - Optional location (lat/lon/alt or privacy flag)
247
+ * @returns Full TPS URI string
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * // With coordinates
252
+ * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
253
+ * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
254
+ *
255
+ * // With privacy flag
256
+ * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
257
+ * // "tps://hidden@T:hij.y1447.M07.d21"
258
+ *
259
+ * // Without location
260
+ * TPS.fromCalendarDate('hij', '1447-07-21');
261
+ * // "tps://unknown@T:hij.y1447.M07.d21"
262
+ * ```
263
+ */
264
+ static fromCalendarDate(calendar, dateString, location) {
265
+ const components = this.parseCalendarDate(calendar, dateString);
266
+ if (!components) {
267
+ throw new Error(`Failed to parse date string: ${dateString}`);
268
+ }
269
+ // Merge with location
270
+ const fullComponents = {
271
+ calendar,
272
+ ...components,
273
+ ...location,
274
+ };
275
+ return this.toURI(fullComponents);
276
+ }
277
+ /**
278
+ * Format TPS components to a calendar-specific date string.
279
+ * Requires the driver to implement the optional `format` method.
280
+ *
281
+ * @param calendar - The calendar code
282
+ * @param components - TPS components to format
283
+ * @param format - Optional format string (driver-specific)
284
+ * @returns Formatted date string in calendar-native format
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.M07.d21');
289
+ * const formatted = TPS.formatCalendarDate('hij', tps);
290
+ * // "1447-07-21"
291
+ * ```
292
+ */
293
+ static formatCalendarDate(calendar, components, format) {
294
+ const driver = this.drivers.get(calendar);
295
+ if (!driver) {
296
+ throw new Error(`Calendar driver '${calendar}' not found.`);
297
+ }
298
+ if (!driver.format) {
299
+ throw new Error(`Driver '${calendar}' does not implement format().`);
300
+ }
301
+ return driver.format(components, format);
302
+ }
175
303
  // --- INTERNAL HELPERS ---
176
304
  static _mapGroupsToComponents(g) {
177
305
  const components = {};
@@ -198,14 +326,50 @@ class TPS {
198
326
  if (g.second)
199
327
  components.second = parseFloat(g.second);
200
328
  }
329
+ // Signature Mapping
330
+ if (g.signature) {
331
+ components.signature = g.signature;
332
+ }
333
+ // Actor Mapping
334
+ if (g.actor) {
335
+ components.actor = g.actor;
336
+ }
201
337
  // Space Mapping
202
338
  if (g.space) {
203
- if (g.space === 'unknown')
339
+ // Privacy markers
340
+ if (g.space === 'unknown' || g.space === '-') {
204
341
  components.isUnknownLocation = true;
205
- else if (g.space === 'redacted')
342
+ }
343
+ else if (g.space === 'redacted') {
206
344
  components.isRedactedLocation = true;
207
- else if (g.space === 'hidden')
345
+ }
346
+ else if (g.space === 'hidden' || g.space === '~') {
208
347
  components.isHiddenLocation = true;
348
+ }
349
+ // Geospatial cells
350
+ else if (g.s2) {
351
+ components.s2Cell = g.s2;
352
+ }
353
+ else if (g.h3) {
354
+ components.h3Cell = g.h3;
355
+ }
356
+ else if (g.plus) {
357
+ components.plusCode = g.plus;
358
+ }
359
+ else if (g.w3w) {
360
+ components.what3words = g.w3w;
361
+ }
362
+ // Structural anchors
363
+ else if (g.bldg) {
364
+ components.building = g.bldg;
365
+ if (g.floor)
366
+ components.floor = g.floor;
367
+ if (g.room)
368
+ components.room = g.room;
369
+ if (g.zone)
370
+ components.zone = g.zone;
371
+ }
372
+ // GPS coordinates
209
373
  else {
210
374
  if (g.lat)
211
375
  components.latitude = parseFloat(g.lat);
@@ -220,10 +384,20 @@ class TPS {
220
384
  const extObj = {};
221
385
  const parts = g.extensions.split('.');
222
386
  parts.forEach((p) => {
223
- const key = p.charAt(0);
224
- const val = p.substring(1);
225
- if (key && val)
226
- extObj[key] = val;
387
+ const eqIdx = p.indexOf('=');
388
+ if (eqIdx > 0) {
389
+ const key = p.substring(0, eqIdx);
390
+ const val = p.substring(eqIdx + 1);
391
+ if (key && val)
392
+ extObj[key] = val;
393
+ }
394
+ else {
395
+ // Legacy format: first char is key
396
+ const key = p.charAt(0);
397
+ const val = p.substring(1);
398
+ if (key && val)
399
+ extObj[key] = val;
400
+ }
227
401
  });
228
402
  components.extensions = extObj;
229
403
  }
@@ -238,5 +412,668 @@ exports.TPS = TPS;
238
412
  // --- PLUGIN REGISTRY ---
239
413
  TPS.drivers = new Map();
240
414
  // --- REGEX ---
241
- TPS.REGEX_URI = new RegExp('^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\\.\\-\\_]+))?$');
242
- TPS.REGEX_TIME = new RegExp('^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+)?))?)?)?)?)?)?)?)?$');
415
+ // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
416
+ // Note: Complex regex - carefully balanced parentheses
417
+ TPS.REGEX_URI = new RegExp('^tps://' +
418
+ // Location part (L: prefix optional for backward compat)
419
+ '(?:L:)?(?<space>' +
420
+ '~|-|unknown|redacted|hidden|' + // Privacy markers
421
+ 's2=(?<s2>[a-fA-F0-9]+)|' + // S2 cell
422
+ 'h3=(?<h3>[a-fA-F0-9]+)|' + // H3 cell
423
+ 'plus=(?<plus>[A-Z0-9+]+)|' + // Plus Code
424
+ 'w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|' + // what3words
425
+ 'bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|' + // Structural
426
+ '(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?' + // GPS
427
+ ')' +
428
+ // Optional Actor anchor
429
+ '(?:/A:(?<actor>[^/@]+))?' +
430
+ // Time part separator
431
+ '[/@]T:(?<calendar>[a-z]{3,4})\\.' +
432
+ // Time components
433
+ '(?:(?<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+)?))?)?)?)?)?)?)?)' +
434
+ // Optional signature
435
+ '(?:!(?<signature>[^;?#]+))?' +
436
+ // Optional extensions
437
+ '(?:;(?<extensions>[a-z0-9.\\-_=]+))?' +
438
+ // Optional query params
439
+ '(?:\\?(?<params>[^#]+))?' +
440
+ // Optional context
441
+ '(?:#(?<context>.+))?$');
442
+ TPS.REGEX_TIME = new RegExp('^T:(?<calendar>[a-z]{3,4})\\.' +
443
+ '(?:(?<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+)?))?)?)?)?)?)?)?)' +
444
+ '(?:!(?<signature>[^;?#]+))?$');
445
+ /**
446
+ * TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
447
+ *
448
+ * A time-first, reversible identifier that binds an event to a TPS coordinate.
449
+ * Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
450
+ * reconstruction of the original TPS string.
451
+ *
452
+ * Binary Schema (all integers big-endian):
453
+ * ```
454
+ * MAGIC 4 bytes "TPU7"
455
+ * VER 1 byte 0x01
456
+ * FLAGS 1 byte bit0 = compression flag
457
+ * TIME 6 bytes epoch_ms (48-bit unsigned)
458
+ * NONCE 4 bytes 32-bit random
459
+ * LEN varint length of TPS payload
460
+ * TPS bytes UTF-8 TPS string (raw or zlib-compressed)
461
+ * ```
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.M01.d09';
466
+ *
467
+ * // Encode to binary
468
+ * const bytes = TPSUID7RB.encodeBinary(tps);
469
+ *
470
+ * // Encode to base64url string
471
+ * const id = TPSUID7RB.encodeBinaryB64(tps);
472
+ * // → "tpsuid7rb_AFRQV..."
473
+ *
474
+ * // Decode back to original TPS
475
+ * const decoded = TPSUID7RB.decodeBinaryB64(id);
476
+ * console.log(decoded.tps); // exact original TPS
477
+ * ```
478
+ */
479
+ class TPSUID7RB {
480
+ // ---------------------------
481
+ // Public API
482
+ // ---------------------------
483
+ /**
484
+ * Encode TPS string to binary bytes (Uint8Array).
485
+ * This is the canonical form for hashing, signing, and storage.
486
+ *
487
+ * @param tps - The TPS string to encode
488
+ * @param opts - Encoding options (compress, epochMs override)
489
+ * @returns Binary TPS-UID as Uint8Array
490
+ */
491
+ static encodeBinary(tps, opts) {
492
+ const compress = opts?.compress ?? false;
493
+ const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
494
+ if (!Number.isInteger(epochMs) || epochMs < 0) {
495
+ throw new Error('epochMs must be a non-negative integer');
496
+ }
497
+ if (epochMs > 0xffffffffffff) {
498
+ throw new Error('epochMs exceeds 48-bit range');
499
+ }
500
+ const flags = compress ? 0x01 : 0x00;
501
+ // Generate 32-bit nonce
502
+ const nonceBuf = this.randomBytes(4);
503
+ const nonce = ((nonceBuf[0] << 24) >>> 0) +
504
+ ((nonceBuf[1] << 16) >>> 0) +
505
+ ((nonceBuf[2] << 8) >>> 0) +
506
+ nonceBuf[3];
507
+ // Encode TPS to UTF-8
508
+ const tpsUtf8 = new TextEncoder().encode(tps);
509
+ // Optionally compress
510
+ const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
511
+ // Encode length as varint
512
+ const lenVar = this.uvarintEncode(payload.length);
513
+ // Construct binary structure
514
+ const out = new Uint8Array(4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length);
515
+ let offset = 0;
516
+ // MAGIC
517
+ out.set(this.MAGIC, offset);
518
+ offset += 4;
519
+ // VER
520
+ out[offset++] = this.VER;
521
+ // FLAGS
522
+ out[offset++] = flags;
523
+ // TIME (48-bit big-endian)
524
+ const timeBytes = this.writeU48(epochMs);
525
+ out.set(timeBytes, offset);
526
+ offset += 6;
527
+ // NONCE (32-bit big-endian)
528
+ out.set(nonceBuf, offset);
529
+ offset += 4;
530
+ // LEN (varint)
531
+ out.set(lenVar, offset);
532
+ offset += lenVar.length;
533
+ // TPS payload
534
+ out.set(payload, offset);
535
+ return out;
536
+ }
537
+ /**
538
+ * Decode binary bytes back to original TPS string.
539
+ *
540
+ * @param bytes - Binary TPS-UID
541
+ * @returns Decoded result with original TPS string
542
+ */
543
+ static decodeBinary(bytes) {
544
+ // Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
545
+ if (bytes.length < 17) {
546
+ throw new Error('TPSUID7RB: too short');
547
+ }
548
+ // MAGIC
549
+ if (bytes[0] !== 0x54 ||
550
+ bytes[1] !== 0x50 ||
551
+ bytes[2] !== 0x55 ||
552
+ bytes[3] !== 0x37) {
553
+ throw new Error('TPSUID7RB: bad magic');
554
+ }
555
+ // VERSION
556
+ const ver = bytes[4];
557
+ if (ver !== this.VER) {
558
+ throw new Error(`TPSUID7RB: unsupported version ${ver}`);
559
+ }
560
+ // FLAGS
561
+ const flags = bytes[5];
562
+ const compressed = (flags & 0x01) === 0x01;
563
+ // TIME (48-bit big-endian)
564
+ const epochMs = this.readU48(bytes, 6);
565
+ // NONCE (32-bit big-endian)
566
+ const nonce = ((bytes[12] << 24) >>> 0) +
567
+ ((bytes[13] << 16) >>> 0) +
568
+ ((bytes[14] << 8) >>> 0) +
569
+ bytes[15];
570
+ // LEN (varint at offset 16)
571
+ let offset = 16;
572
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
573
+ offset += bytesRead;
574
+ if (offset + tpsLen > bytes.length) {
575
+ throw new Error('TPSUID7RB: length overflow');
576
+ }
577
+ // TPS payload
578
+ const payload = bytes.slice(offset, offset + tpsLen);
579
+ const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
580
+ const tps = new TextDecoder().decode(tpsUtf8);
581
+ return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
582
+ }
583
+ /**
584
+ * Encode TPS to base64url string with prefix.
585
+ * This is the transport/storage form.
586
+ *
587
+ * @param tps - The TPS string to encode
588
+ * @param opts - Encoding options
589
+ * @returns Base64url encoded TPS-UID with prefix
590
+ */
591
+ static encodeBinaryB64(tps, opts) {
592
+ const bytes = this.encodeBinary(tps, opts);
593
+ return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
594
+ }
595
+ /**
596
+ * Decode base64url string back to original TPS string.
597
+ *
598
+ * @param id - Base64url encoded TPS-UID with prefix
599
+ * @returns Decoded result with original TPS string
600
+ */
601
+ static decodeBinaryB64(id) {
602
+ const s = id.trim();
603
+ if (!s.startsWith(this.PREFIX)) {
604
+ throw new Error('TPSUID7RB: missing prefix');
605
+ }
606
+ const b64 = s.slice(this.PREFIX.length);
607
+ const bytes = this.base64UrlDecode(b64);
608
+ return this.decodeBinary(bytes);
609
+ }
610
+ /**
611
+ * Validate base64url encoded TPS-UID format.
612
+ * Note: This validates shape only; binary decode is authoritative.
613
+ *
614
+ * @param id - String to validate
615
+ * @returns true if format is valid
616
+ */
617
+ static validateBinaryB64(id) {
618
+ return this.REGEX.test(id.trim());
619
+ }
620
+ /**
621
+ * Generate a TPS-UID from the current time and optional location.
622
+ *
623
+ * @param opts - Generation options
624
+ * @returns Base64url encoded TPS-UID
625
+ */
626
+ static generate(opts) {
627
+ const now = new Date();
628
+ const tps = this.generateTPSString(now, opts);
629
+ return this.encodeBinaryB64(tps, {
630
+ compress: opts?.compress,
631
+ epochMs: now.getTime(),
632
+ });
633
+ }
634
+ // ---------------------------
635
+ // TPS String Helpers
636
+ // ---------------------------
637
+ /**
638
+ * Generate a TPS string from a Date and optional location.
639
+ */
640
+ static generateTPSString(date, opts) {
641
+ const fullYear = date.getUTCFullYear();
642
+ const m = Math.floor(fullYear / 1000) + 1;
643
+ const c = Math.floor((fullYear % 1000) / 100) + 1;
644
+ const y = fullYear % 100;
645
+ const M = date.getUTCMonth() + 1;
646
+ const d = date.getUTCDate();
647
+ const h = date.getUTCHours();
648
+ const n = date.getUTCMinutes();
649
+ const s = date.getUTCSeconds();
650
+ const pad = (num) => num.toString().padStart(2, '0');
651
+ const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(h)}.n${pad(n)}.s${pad(s)}`;
652
+ let spacePart = 'unknown';
653
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
654
+ spacePart = `${opts.latitude},${opts.longitude}`;
655
+ if (opts.altitude !== undefined) {
656
+ spacePart += `,${opts.altitude}m`;
657
+ }
658
+ }
659
+ return `tps://${spacePart}@${timePart}`;
660
+ }
661
+ /**
662
+ * Parse epoch milliseconds from a TPS string.
663
+ * Supports both URI format (tps://...) and time-only format (T:greg...)
664
+ */
665
+ static epochMsFromTPSString(tps) {
666
+ let time;
667
+ if (tps.includes('@')) {
668
+ // URI format: tps://...@T:greg...
669
+ const at = tps.indexOf('@');
670
+ time = tps.slice(at + 1).trim();
671
+ }
672
+ else if (tps.startsWith('T:')) {
673
+ // Time-only format
674
+ time = tps;
675
+ }
676
+ else {
677
+ throw new Error('TPS: unrecognized format');
678
+ }
679
+ if (!time.startsWith('T:greg.')) {
680
+ throw new Error('TPS: only T:greg.* parsing is supported');
681
+ }
682
+ // Extract m (millennium), c (century), y (year)
683
+ const mMatch = time.match(/\.m(-?\d+)/);
684
+ const cMatch = time.match(/\.c(\d+)/);
685
+ const yMatch = time.match(/\.y(\d{1,4})/);
686
+ const MMatch = time.match(/\.M(\d{1,2})/);
687
+ const dMatch = time.match(/\.d(\d{1,2})/);
688
+ const hMatch = time.match(/\.h(\d{1,2})/);
689
+ const nMatch = time.match(/\.n(\d{1,2})/);
690
+ const sMatch = time.match(/\.s(\d{1,2})/);
691
+ // Calculate full year from millennium, century, year
692
+ let fullYear;
693
+ if (mMatch && cMatch && yMatch) {
694
+ const millennium = parseInt(mMatch[1], 10);
695
+ const century = parseInt(cMatch[1], 10);
696
+ const year = parseInt(yMatch[1], 10);
697
+ fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
698
+ }
699
+ else if (yMatch) {
700
+ // Fallback: interpret y as 2-digit year
701
+ let year = parseInt(yMatch[1], 10);
702
+ if (year < 100) {
703
+ year = year <= 69 ? 2000 + year : 1900 + year;
704
+ }
705
+ fullYear = year;
706
+ }
707
+ else {
708
+ throw new Error('TPS: missing year component');
709
+ }
710
+ const month = MMatch ? parseInt(MMatch[1], 10) : 1;
711
+ const day = dMatch ? parseInt(dMatch[1], 10) : 1;
712
+ const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
713
+ const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
714
+ const second = sMatch ? parseInt(sMatch[1], 10) : 0;
715
+ const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
716
+ if (!Number.isFinite(epoch)) {
717
+ throw new Error('TPS: failed to compute epochMs');
718
+ }
719
+ return epoch;
720
+ }
721
+ // ---------------------------
722
+ // Binary Helpers
723
+ // ---------------------------
724
+ /** Write 48-bit unsigned integer (big-endian) */
725
+ static writeU48(epochMs) {
726
+ const b = new Uint8Array(6);
727
+ // Use BigInt for proper 48-bit handling
728
+ const v = BigInt(epochMs);
729
+ b[0] = Number((v >> 40n) & 0xffn);
730
+ b[1] = Number((v >> 32n) & 0xffn);
731
+ b[2] = Number((v >> 24n) & 0xffn);
732
+ b[3] = Number((v >> 16n) & 0xffn);
733
+ b[4] = Number((v >> 8n) & 0xffn);
734
+ b[5] = Number(v & 0xffn);
735
+ return b;
736
+ }
737
+ /** Read 48-bit unsigned integer (big-endian) */
738
+ static readU48(bytes, offset) {
739
+ const v = (BigInt(bytes[offset]) << 40n) +
740
+ (BigInt(bytes[offset + 1]) << 32n) +
741
+ (BigInt(bytes[offset + 2]) << 24n) +
742
+ (BigInt(bytes[offset + 3]) << 16n) +
743
+ (BigInt(bytes[offset + 4]) << 8n) +
744
+ BigInt(bytes[offset + 5]);
745
+ const n = Number(v);
746
+ if (!Number.isSafeInteger(n)) {
747
+ throw new Error('TPSUID7RB: u48 not safe integer');
748
+ }
749
+ return n;
750
+ }
751
+ /** Encode unsigned integer as LEB128 varint */
752
+ static uvarintEncode(n) {
753
+ if (!Number.isInteger(n) || n < 0) {
754
+ throw new Error('uvarint must be non-negative int');
755
+ }
756
+ const out = [];
757
+ let x = n >>> 0;
758
+ while (x >= 0x80) {
759
+ out.push((x & 0x7f) | 0x80);
760
+ x >>>= 7;
761
+ }
762
+ out.push(x);
763
+ return new Uint8Array(out);
764
+ }
765
+ /** Decode LEB128 varint */
766
+ static uvarintDecode(bytes, offset) {
767
+ let x = 0;
768
+ let s = 0;
769
+ let i = 0;
770
+ while (true) {
771
+ if (offset + i >= bytes.length) {
772
+ throw new Error('uvarint overflow');
773
+ }
774
+ const b = bytes[offset + i];
775
+ if (b < 0x80) {
776
+ if (i > 9 || (i === 9 && b > 1)) {
777
+ throw new Error('uvarint too large');
778
+ }
779
+ x |= b << s;
780
+ return { value: x >>> 0, bytesRead: i + 1 };
781
+ }
782
+ x |= (b & 0x7f) << s;
783
+ s += 7;
784
+ i++;
785
+ if (i > 10) {
786
+ throw new Error('uvarint too long');
787
+ }
788
+ }
789
+ }
790
+ // ---------------------------
791
+ // Base64url Helpers
792
+ // ---------------------------
793
+ /** Encode bytes to base64url (no padding) */
794
+ static base64UrlEncode(bytes) {
795
+ // Node.js environment
796
+ if (typeof Buffer !== 'undefined') {
797
+ return Buffer.from(bytes)
798
+ .toString('base64')
799
+ .replace(/\+/g, '-')
800
+ .replace(/\//g, '_')
801
+ .replace(/=+$/g, '');
802
+ }
803
+ // Browser environment
804
+ let binary = '';
805
+ for (let i = 0; i < bytes.length; i++) {
806
+ binary += String.fromCharCode(bytes[i]);
807
+ }
808
+ return btoa(binary)
809
+ .replace(/\+/g, '-')
810
+ .replace(/\//g, '_')
811
+ .replace(/=+$/g, '');
812
+ }
813
+ /** Decode base64url to bytes */
814
+ static base64UrlDecode(b64url) {
815
+ // Add padding
816
+ const padLen = (4 - (b64url.length % 4)) % 4;
817
+ const b64 = (b64url + '='.repeat(padLen))
818
+ .replace(/-/g, '+')
819
+ .replace(/_/g, '/');
820
+ // Node.js environment
821
+ if (typeof Buffer !== 'undefined') {
822
+ return new Uint8Array(Buffer.from(b64, 'base64'));
823
+ }
824
+ // Browser environment
825
+ const binary = atob(b64);
826
+ const bytes = new Uint8Array(binary.length);
827
+ for (let i = 0; i < binary.length; i++) {
828
+ bytes[i] = binary.charCodeAt(i);
829
+ }
830
+ return bytes;
831
+ }
832
+ // ---------------------------
833
+ // Compression Helpers
834
+ // ---------------------------
835
+ /** Compress using zlib deflate raw */
836
+ static deflateRaw(data) {
837
+ // Node.js environment
838
+ if (typeof require !== 'undefined') {
839
+ try {
840
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
841
+ const zlib = require('zlib');
842
+ return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
843
+ }
844
+ catch {
845
+ throw new Error('TPSUID7RB: compression not available');
846
+ }
847
+ }
848
+ // Browser: would need pako or similar library
849
+ throw new Error('TPSUID7RB: compression not available in browser');
850
+ }
851
+ /** Decompress using zlib inflate raw */
852
+ static inflateRaw(data) {
853
+ // Node.js environment
854
+ if (typeof require !== 'undefined') {
855
+ try {
856
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
857
+ const zlib = require('zlib');
858
+ return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
859
+ }
860
+ catch {
861
+ throw new Error('TPSUID7RB: decompression failed');
862
+ }
863
+ }
864
+ // Browser: would need pako or similar library
865
+ throw new Error('TPSUID7RB: decompression not available in browser');
866
+ }
867
+ // ---------------------------
868
+ // Cryptographic Sealing (Ed25519)
869
+ // ---------------------------
870
+ /**
871
+ * Seal (sign) a TPS string to create a cryptographically verifiable TPS-UID.
872
+ * This appends an Ed25519 signature to the binary form.
873
+ *
874
+ * @param tps - The TPS string to seal
875
+ * @param privateKey - Ed25519 private key (hex or buffer)
876
+ * @param opts - Encoding options
877
+ * @returns Sealed binary TPS-UID
878
+ */
879
+ static seal(tps, privateKey, opts) {
880
+ // 1. Create standard binary (unsealed first)
881
+ // We force the SEAL flag (bit 1) to be 0 initially for the "content to sign"
882
+ // But wait, we want the signature to cover the header too.
883
+ // Strategy: Construct the full binary with SEAL flag OFF, sign it, then set SEAL flag ON and append sig.
884
+ // Actually, the standard way is:
885
+ // Content = MAGIC + VER + FLAGS(with seal bit set) + TIME + NONCE + LEN + PAYLOAD
886
+ // Signature = Sign(Content)
887
+ // Final = Content + SEAL_TYPE + SIGNATURE
888
+ const compress = opts?.compress ?? false;
889
+ const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
890
+ // Validate epoch
891
+ if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
892
+ throw new Error('epochMs must be a valid 48-bit non-negative integer');
893
+ }
894
+ // Flags: Bit 0 = compress, Bit 1 = sealed
895
+ const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
896
+ // Generate Nonce
897
+ const nonceBuf = this.randomBytes(4);
898
+ // Encode Payload
899
+ const tpsUtf8 = new TextEncoder().encode(tps);
900
+ const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
901
+ const lenVar = this.uvarintEncode(payload.length);
902
+ // Construct Content (Header + Payload)
903
+ const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
904
+ const content = new Uint8Array(contentLen);
905
+ let offset = 0;
906
+ content.set(this.MAGIC, offset);
907
+ offset += 4;
908
+ content[offset++] = this.VER;
909
+ content[offset++] = flags;
910
+ content.set(this.writeU48(epochMs), offset);
911
+ offset += 6;
912
+ content.set(nonceBuf, offset);
913
+ offset += 4;
914
+ content.set(lenVar, offset);
915
+ offset += lenVar.length;
916
+ content.set(payload, offset);
917
+ // Sign the content
918
+ const signature = this.signEd25519(content, privateKey);
919
+ const sealType = 0x01; // Ed25519
920
+ // Final Output: Content + SealType (1) + Signature (64)
921
+ const final = new Uint8Array(contentLen + 1 + signature.length);
922
+ final.set(content, 0);
923
+ final.set([sealType], contentLen);
924
+ final.set(signature, contentLen + 1);
925
+ return final;
926
+ }
927
+ /**
928
+ * Verify a sealed TPS-UID and decode it.
929
+ * Throws if signature is invalid or not sealed.
930
+ *
931
+ * @param sealedBytes - The binary sealed TPS-UID
932
+ * @param publicKey - Ed25519 public key (hex or buffer) to verify against
933
+ * @returns Decoded result
934
+ */
935
+ static verifyAndDecode(sealedBytes, publicKey) {
936
+ if (sealedBytes.length < 18)
937
+ throw new Error('TPSUID7RB: too short');
938
+ // Check Magic
939
+ if (sealedBytes[0] !== 0x54 ||
940
+ sealedBytes[1] !== 0x50 ||
941
+ sealedBytes[2] !== 0x55 ||
942
+ sealedBytes[3] !== 0x37) {
943
+ throw new Error('TPSUID7RB: bad magic');
944
+ }
945
+ // Check Flags for Sealed Bit (bit 1)
946
+ const flags = sealedBytes[5];
947
+ if ((flags & 0x02) === 0) {
948
+ throw new Error('TPSUID7RB: not a sealed UID');
949
+ }
950
+ // 1. Parse the structure to find where content ends
951
+ // We need to parse LEN and Payload to find the split point
952
+ let offset = 16; // Start of LEN
953
+ // Decode LEN
954
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(sealedBytes, offset);
955
+ offset += bytesRead;
956
+ const payloadEnd = offset + tpsLen;
957
+ if (payloadEnd > sealedBytes.length) {
958
+ throw new Error('TPSUID7RB: length overflow (truncated)');
959
+ }
960
+ // The Content to verify matches exactly [0 ... payloadEnd]
961
+ const content = sealedBytes.slice(0, payloadEnd);
962
+ // After content: SealType (1 byte) + Signature
963
+ if (sealedBytes.length <= payloadEnd + 1) {
964
+ throw new Error('TPSUID7RB: missing signature data');
965
+ }
966
+ const sealType = sealedBytes[payloadEnd];
967
+ if (sealType !== 0x01) {
968
+ throw new Error(`TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`);
969
+ }
970
+ const signature = sealedBytes.slice(payloadEnd + 1);
971
+ if (signature.length !== 64) {
972
+ throw new Error(`TPSUID7RB: invalid Ed25519 signature length ${signature.length}`);
973
+ }
974
+ // Verify
975
+ const isValid = this.verifyEd25519(content, signature, publicKey);
976
+ if (!isValid) {
977
+ throw new Error('TPSUID7RB: signature verification failed');
978
+ }
979
+ // Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
980
+ // Actually standard logic doesn't expect trailing bytes unless we tell it to.
981
+ // But since we verified, we can just slice the content and decode that as a strict binary
982
+ // EXCEPT standard decodeBinary checks strict length.
983
+ // So we manually decode the components here to be safe and efficient.
984
+ return this.decodeBinary(content); // Reuse strict decoder on the content part
985
+ }
986
+ // --- Crypto Implementation (Ed25519) ---
987
+ static signEd25519(data, privateKey) {
988
+ if (typeof require !== 'undefined') {
989
+ try {
990
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
991
+ const crypto = require('crypto');
992
+ // Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
993
+ // or ensure key is properly formatted.
994
+ // For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
995
+ // If raw bytes: establish KeyObject.
996
+ let keyObj;
997
+ if (Buffer.isBuffer(privateKey) || privateKey instanceof Uint8Array) {
998
+ // Assuming raw 64-byte private key (or 32-byte seed properly expanded by crypto)
999
+ // Node < 16 is tricky with raw keys.
1000
+ // Let's assume standard Ed25519 standard implementation pattern logic:
1001
+ keyObj = crypto.createPrivateKey({
1002
+ key: Buffer.from(privateKey),
1003
+ format: 'der', // or 'pem' - strict.
1004
+ type: 'pkcs8',
1005
+ });
1006
+ // Actually, simpler: construct key object from raw bytes if possible?
1007
+ // Node's crypto is strict. Let's try the simplest:
1008
+ // If hex string provided, convert to buffer.
1009
+ }
1010
+ // Simpler fallback: If user passed a PEM string, great.
1011
+ // If they passed raw bytes, we might need 'ed25519' key type.
1012
+ // For this implementation, let's target Node's high-level sign/verify
1013
+ // and assume the user provides a VALID key object or compatible format (PEM/DER).
1014
+ // Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
1015
+ const key = typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
1016
+ ? crypto.createPrivateKey({
1017
+ key: Buffer.from(privateKey, 'hex'),
1018
+ format: 'pem',
1019
+ type: 'pkcs8',
1020
+ }) // Fallback guess
1021
+ : privateKey;
1022
+ // Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
1023
+ // Direct raw signing is via crypto.sign(null, data, key).
1024
+ return new Uint8Array(crypto.sign(null, data, key));
1025
+ }
1026
+ catch (e) {
1027
+ // If standard crypto fails (e.g. key format issue), throw
1028
+ throw new Error('TPSUID7RB: signing failed (check key format)');
1029
+ }
1030
+ }
1031
+ throw new Error('TPSUID7RB: signing not available in browser');
1032
+ }
1033
+ static verifyEd25519(data, signature, publicKey) {
1034
+ if (typeof require !== 'undefined') {
1035
+ try {
1036
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1037
+ const crypto = require('crypto');
1038
+ return crypto.verify(null, data, publicKey, signature);
1039
+ }
1040
+ catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+ throw new Error('TPSUID7RB: verification not available in browser');
1045
+ }
1046
+ // ---------------------------
1047
+ // Random Bytes
1048
+ // ---------------------------
1049
+ /** Generate cryptographically secure random bytes */
1050
+ static randomBytes(length) {
1051
+ // Node.js environment
1052
+ if (typeof require !== 'undefined') {
1053
+ try {
1054
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1055
+ const crypto = require('crypto');
1056
+ return new Uint8Array(crypto.randomBytes(length));
1057
+ }
1058
+ catch {
1059
+ // Fallback to crypto.getRandomValues
1060
+ }
1061
+ }
1062
+ // Browser or fallback
1063
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1064
+ const bytes = new Uint8Array(length);
1065
+ crypto.getRandomValues(bytes);
1066
+ return bytes;
1067
+ }
1068
+ throw new Error('TPSUID7RB: no crypto available');
1069
+ }
1070
+ }
1071
+ exports.TPSUID7RB = TPSUID7RB;
1072
+ /** Magic bytes: "TPU7" */
1073
+ TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
1074
+ /** Version 1 */
1075
+ TPSUID7RB.VER = 0x01;
1076
+ /** String prefix for base64url encoded form */
1077
+ TPSUID7RB.PREFIX = 'tpsuid7rb_';
1078
+ /** Regex for validating base64url encoded form */
1079
+ TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;