@nextera.one/tps-standard 0.4.3 → 0.5.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.
- package/README.md +268 -49
- package/dist/index.d.ts +366 -2
- package/dist/index.js +724 -1
- package/dist/src/index.js +693 -0
- package/dist/test/src/index.js +960 -0
- package/dist/test/test/persian-calendar.test.js +488 -0
- package/dist/test/test/tps-uid.test.js +295 -0
- package/dist/test/tps-uid.test.js +240 -0
- package/package.json +3 -2
- package/src/index.ts +1031 -2
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TPS: Temporal Positioning System
|
|
3
|
+
* The Universal Protocol for Space-Time Coordinates.
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
* @version 0.4.2
|
|
6
|
+
* @license MIT
|
|
7
|
+
* @copyright 2026 TPS Standards Working Group
|
|
8
|
+
*/
|
|
9
|
+
export class TPS {
|
|
10
|
+
/**
|
|
11
|
+
* Registers a calendar driver plugin.
|
|
12
|
+
* @param driver - The driver instance to register.
|
|
13
|
+
*/
|
|
14
|
+
static registerDriver(driver) {
|
|
15
|
+
this.drivers.set(driver.code, driver);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Gets a registered calendar driver.
|
|
19
|
+
* @param code - The calendar code.
|
|
20
|
+
* @returns The driver or undefined.
|
|
21
|
+
*/
|
|
22
|
+
static getDriver(code) {
|
|
23
|
+
return this.drivers.get(code);
|
|
24
|
+
}
|
|
25
|
+
// --- CORE METHODS ---
|
|
26
|
+
static validate(input) {
|
|
27
|
+
if (input.startsWith('tps://'))
|
|
28
|
+
return this.REGEX_URI.test(input);
|
|
29
|
+
return this.REGEX_TIME.test(input);
|
|
30
|
+
}
|
|
31
|
+
static parse(input) {
|
|
32
|
+
if (input.startsWith('tps://')) {
|
|
33
|
+
const match = this.REGEX_URI.exec(input);
|
|
34
|
+
if (!match || !match.groups)
|
|
35
|
+
return null;
|
|
36
|
+
return this._mapGroupsToComponents(match.groups);
|
|
37
|
+
}
|
|
38
|
+
const match = this.REGEX_TIME.exec(input);
|
|
39
|
+
if (!match || !match.groups)
|
|
40
|
+
return null;
|
|
41
|
+
return this._mapGroupsToComponents(match.groups);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* SERIALIZER: Converts a components object into a full TPS URI.
|
|
45
|
+
* @param comp - The TPS components.
|
|
46
|
+
* @returns Full URI string (e.g. "tps://...").
|
|
47
|
+
*/
|
|
48
|
+
static toURI(comp) {
|
|
49
|
+
// 1. Build Space Part
|
|
50
|
+
let spacePart = 'unknown'; // Default safe fallback
|
|
51
|
+
if (comp.isHiddenLocation) {
|
|
52
|
+
spacePart = 'hidden';
|
|
53
|
+
}
|
|
54
|
+
else if (comp.isRedactedLocation) {
|
|
55
|
+
spacePart = 'redacted';
|
|
56
|
+
}
|
|
57
|
+
else if (comp.isUnknownLocation) {
|
|
58
|
+
spacePart = 'unknown';
|
|
59
|
+
}
|
|
60
|
+
else if (comp.latitude !== undefined && comp.longitude !== undefined) {
|
|
61
|
+
spacePart = `${comp.latitude},${comp.longitude}`;
|
|
62
|
+
if (comp.altitude !== undefined) {
|
|
63
|
+
spacePart += `,${comp.altitude}m`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 2. Build Time Part
|
|
67
|
+
let timePart = `T:${comp.calendar}`;
|
|
68
|
+
if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
|
|
69
|
+
timePart += `.s${comp.unixSeconds}`;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
if (comp.millennium !== undefined)
|
|
73
|
+
timePart += `.m${comp.millennium}`;
|
|
74
|
+
if (comp.century !== undefined)
|
|
75
|
+
timePart += `.c${comp.century}`;
|
|
76
|
+
if (comp.year !== undefined)
|
|
77
|
+
timePart += `.y${comp.year}`;
|
|
78
|
+
if (comp.month !== undefined)
|
|
79
|
+
timePart += `.M${this.pad(comp.month)}`;
|
|
80
|
+
if (comp.day !== undefined)
|
|
81
|
+
timePart += `.d${this.pad(comp.day)}`;
|
|
82
|
+
if (comp.hour !== undefined)
|
|
83
|
+
timePart += `.h${this.pad(comp.hour)}`;
|
|
84
|
+
if (comp.minute !== undefined)
|
|
85
|
+
timePart += `.n${this.pad(comp.minute)}`;
|
|
86
|
+
if (comp.second !== undefined)
|
|
87
|
+
timePart += `.s${this.pad(comp.second)}`;
|
|
88
|
+
}
|
|
89
|
+
// 3. Build Extensions
|
|
90
|
+
let extPart = '';
|
|
91
|
+
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
92
|
+
const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}${v}`);
|
|
93
|
+
extPart = `;${extStrings.join('.')}`;
|
|
94
|
+
}
|
|
95
|
+
return `tps://${spacePart}@${timePart}${extPart}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
|
|
99
|
+
* Supports plugin drivers for non-Gregorian calendars.
|
|
100
|
+
* @param date - The JS Date object (defaults to Now).
|
|
101
|
+
* @param calendar - The target calendar driver (default 'greg').
|
|
102
|
+
* @returns Canonical string (e.g., "T:greg.m3.c1.y26...").
|
|
103
|
+
*/
|
|
104
|
+
static fromDate(date = new Date(), calendar = 'greg') {
|
|
105
|
+
// Check for registered driver first
|
|
106
|
+
const driver = this.drivers.get(calendar);
|
|
107
|
+
if (driver) {
|
|
108
|
+
return driver.fromDate(date);
|
|
109
|
+
}
|
|
110
|
+
// Built-in handlers
|
|
111
|
+
if (calendar === 'unix') {
|
|
112
|
+
const s = (date.getTime() / 1000).toFixed(3);
|
|
113
|
+
return `T:unix.s${s}`;
|
|
114
|
+
}
|
|
115
|
+
if (calendar === 'greg') {
|
|
116
|
+
const fullYear = date.getUTCFullYear();
|
|
117
|
+
const m = Math.floor(fullYear / 1000) + 1;
|
|
118
|
+
const c = Math.floor((fullYear % 1000) / 100) + 1;
|
|
119
|
+
const y = fullYear % 100;
|
|
120
|
+
const M = date.getUTCMonth() + 1;
|
|
121
|
+
const d = date.getUTCDate();
|
|
122
|
+
const h = date.getUTCHours();
|
|
123
|
+
const n = date.getUTCMinutes();
|
|
124
|
+
const s = date.getUTCSeconds();
|
|
125
|
+
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)}`;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Calendar driver '${calendar}' not implemented. Register a driver.`);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* CONVERTER: Converts a TPS string to a Date in a target calendar format.
|
|
131
|
+
* Uses plugin drivers for cross-calendar conversion.
|
|
132
|
+
* @param tpsString - The source TPS string (any calendar).
|
|
133
|
+
* @param targetCalendar - The target calendar code (e.g., 'hij').
|
|
134
|
+
* @returns A TPS string in the target calendar, or null if invalid.
|
|
135
|
+
*/
|
|
136
|
+
static to(targetCalendar, tpsString) {
|
|
137
|
+
// 1. Parse to components and convert to Gregorian Date
|
|
138
|
+
const gregDate = this.toDate(tpsString);
|
|
139
|
+
if (!gregDate)
|
|
140
|
+
return null;
|
|
141
|
+
// 2. Convert Gregorian to target calendar using driver
|
|
142
|
+
return this.fromDate(gregDate, targetCalendar);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* CONVERTER: Reconstructs a JavaScript Date object from a TPS string.
|
|
146
|
+
* Supports plugin drivers for non-Gregorian calendars.
|
|
147
|
+
* @param tpsString - The TPS string.
|
|
148
|
+
* @returns JS Date object or `null` if invalid.
|
|
149
|
+
*/
|
|
150
|
+
static toDate(tpsString) {
|
|
151
|
+
const p = this.parse(tpsString);
|
|
152
|
+
if (!p)
|
|
153
|
+
return null;
|
|
154
|
+
// Check for registered driver first
|
|
155
|
+
const driver = this.drivers.get(p.calendar);
|
|
156
|
+
if (driver) {
|
|
157
|
+
return driver.toGregorian(p);
|
|
158
|
+
}
|
|
159
|
+
// Built-in handlers
|
|
160
|
+
if (p.calendar === 'unix' && p.unixSeconds !== undefined) {
|
|
161
|
+
return new Date(p.unixSeconds * 1000);
|
|
162
|
+
}
|
|
163
|
+
if (p.calendar === 'greg') {
|
|
164
|
+
const m = p.millennium || 0;
|
|
165
|
+
const c = p.century || 1;
|
|
166
|
+
const y = p.year || 0;
|
|
167
|
+
const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
168
|
+
return new Date(Date.UTC(fullYear, (p.month || 1) - 1, p.day || 1, p.hour || 0, p.minute || 0, Math.floor(p.second || 0)));
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
// --- INTERNAL HELPERS ---
|
|
173
|
+
static _mapGroupsToComponents(g) {
|
|
174
|
+
const components = {};
|
|
175
|
+
components.calendar = g.calendar;
|
|
176
|
+
// Time Mapping
|
|
177
|
+
if (components.calendar === 'unix' && g.unix) {
|
|
178
|
+
components.unixSeconds = parseFloat(g.unix.substring(1));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
if (g.millennium)
|
|
182
|
+
components.millennium = parseInt(g.millennium, 10);
|
|
183
|
+
if (g.century)
|
|
184
|
+
components.century = parseInt(g.century, 10);
|
|
185
|
+
if (g.year)
|
|
186
|
+
components.year = parseInt(g.year, 10);
|
|
187
|
+
if (g.month)
|
|
188
|
+
components.month = parseInt(g.month, 10);
|
|
189
|
+
if (g.day)
|
|
190
|
+
components.day = parseInt(g.day, 10);
|
|
191
|
+
if (g.hour)
|
|
192
|
+
components.hour = parseInt(g.hour, 10);
|
|
193
|
+
if (g.minute)
|
|
194
|
+
components.minute = parseInt(g.minute, 10);
|
|
195
|
+
if (g.second)
|
|
196
|
+
components.second = parseFloat(g.second);
|
|
197
|
+
}
|
|
198
|
+
// Space Mapping
|
|
199
|
+
if (g.space) {
|
|
200
|
+
if (g.space === 'unknown')
|
|
201
|
+
components.isUnknownLocation = true;
|
|
202
|
+
else if (g.space === 'redacted')
|
|
203
|
+
components.isRedactedLocation = true;
|
|
204
|
+
else if (g.space === 'hidden')
|
|
205
|
+
components.isHiddenLocation = true;
|
|
206
|
+
else {
|
|
207
|
+
if (g.lat)
|
|
208
|
+
components.latitude = parseFloat(g.lat);
|
|
209
|
+
if (g.lon)
|
|
210
|
+
components.longitude = parseFloat(g.lon);
|
|
211
|
+
if (g.alt)
|
|
212
|
+
components.altitude = parseFloat(g.alt);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Extensions Mapping
|
|
216
|
+
if (g.extensions) {
|
|
217
|
+
const extObj = {};
|
|
218
|
+
const parts = g.extensions.split('.');
|
|
219
|
+
parts.forEach((p) => {
|
|
220
|
+
const key = p.charAt(0);
|
|
221
|
+
const val = p.substring(1);
|
|
222
|
+
if (key && val)
|
|
223
|
+
extObj[key] = val;
|
|
224
|
+
});
|
|
225
|
+
components.extensions = extObj;
|
|
226
|
+
}
|
|
227
|
+
return components;
|
|
228
|
+
}
|
|
229
|
+
static pad(n) {
|
|
230
|
+
const s = n.toString();
|
|
231
|
+
return s.length < 2 ? '0' + s : s;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// --- PLUGIN REGISTRY ---
|
|
235
|
+
TPS.drivers = new Map();
|
|
236
|
+
// --- REGEX ---
|
|
237
|
+
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\\.\\-\\_]+))?$');
|
|
238
|
+
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+)?))?)?)?)?)?)?)?)?$');
|
|
239
|
+
/**
|
|
240
|
+
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
241
|
+
*
|
|
242
|
+
* A time-first, reversible identifier that binds an event to a TPS coordinate.
|
|
243
|
+
* Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
|
|
244
|
+
* reconstruction of the original TPS string.
|
|
245
|
+
*
|
|
246
|
+
* Binary Schema (all integers big-endian):
|
|
247
|
+
* ```
|
|
248
|
+
* MAGIC 4 bytes "TPU7"
|
|
249
|
+
* VER 1 byte 0x01
|
|
250
|
+
* FLAGS 1 byte bit0 = compression flag
|
|
251
|
+
* TIME 6 bytes epoch_ms (48-bit unsigned)
|
|
252
|
+
* NONCE 4 bytes 32-bit random
|
|
253
|
+
* LEN varint length of TPS payload
|
|
254
|
+
* TPS bytes UTF-8 TPS string (raw or zlib-compressed)
|
|
255
|
+
* ```
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.M01.d09';
|
|
260
|
+
*
|
|
261
|
+
* // Encode to binary
|
|
262
|
+
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
263
|
+
*
|
|
264
|
+
* // Encode to base64url string
|
|
265
|
+
* const id = TPSUID7RB.encodeBinaryB64(tps);
|
|
266
|
+
* // → "tpsuid7rb_AFRQV..."
|
|
267
|
+
*
|
|
268
|
+
* // Decode back to original TPS
|
|
269
|
+
* const decoded = TPSUID7RB.decodeBinaryB64(id);
|
|
270
|
+
* console.log(decoded.tps); // exact original TPS
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
export class TPSUID7RB {
|
|
274
|
+
// ---------------------------
|
|
275
|
+
// Public API
|
|
276
|
+
// ---------------------------
|
|
277
|
+
/**
|
|
278
|
+
* Encode TPS string to binary bytes (Uint8Array).
|
|
279
|
+
* This is the canonical form for hashing, signing, and storage.
|
|
280
|
+
*
|
|
281
|
+
* @param tps - The TPS string to encode
|
|
282
|
+
* @param opts - Encoding options (compress, epochMs override)
|
|
283
|
+
* @returns Binary TPS-UID as Uint8Array
|
|
284
|
+
*/
|
|
285
|
+
static encodeBinary(tps, opts) {
|
|
286
|
+
const compress = opts?.compress ?? false;
|
|
287
|
+
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
288
|
+
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
289
|
+
throw new Error('epochMs must be a non-negative integer');
|
|
290
|
+
}
|
|
291
|
+
if (epochMs > 0xffffffffffff) {
|
|
292
|
+
throw new Error('epochMs exceeds 48-bit range');
|
|
293
|
+
}
|
|
294
|
+
const flags = compress ? 0x01 : 0x00;
|
|
295
|
+
// Generate 32-bit nonce
|
|
296
|
+
const nonceBuf = this.randomBytes(4);
|
|
297
|
+
const nonce = ((nonceBuf[0] << 24) >>> 0) +
|
|
298
|
+
((nonceBuf[1] << 16) >>> 0) +
|
|
299
|
+
((nonceBuf[2] << 8) >>> 0) +
|
|
300
|
+
nonceBuf[3];
|
|
301
|
+
// Encode TPS to UTF-8
|
|
302
|
+
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
303
|
+
// Optionally compress
|
|
304
|
+
const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
|
|
305
|
+
// Encode length as varint
|
|
306
|
+
const lenVar = this.uvarintEncode(payload.length);
|
|
307
|
+
// Construct binary structure
|
|
308
|
+
const out = new Uint8Array(4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length);
|
|
309
|
+
let offset = 0;
|
|
310
|
+
// MAGIC
|
|
311
|
+
out.set(this.MAGIC, offset);
|
|
312
|
+
offset += 4;
|
|
313
|
+
// VER
|
|
314
|
+
out[offset++] = this.VER;
|
|
315
|
+
// FLAGS
|
|
316
|
+
out[offset++] = flags;
|
|
317
|
+
// TIME (48-bit big-endian)
|
|
318
|
+
const timeBytes = this.writeU48(epochMs);
|
|
319
|
+
out.set(timeBytes, offset);
|
|
320
|
+
offset += 6;
|
|
321
|
+
// NONCE (32-bit big-endian)
|
|
322
|
+
out.set(nonceBuf, offset);
|
|
323
|
+
offset += 4;
|
|
324
|
+
// LEN (varint)
|
|
325
|
+
out.set(lenVar, offset);
|
|
326
|
+
offset += lenVar.length;
|
|
327
|
+
// TPS payload
|
|
328
|
+
out.set(payload, offset);
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Decode binary bytes back to original TPS string.
|
|
333
|
+
*
|
|
334
|
+
* @param bytes - Binary TPS-UID
|
|
335
|
+
* @returns Decoded result with original TPS string
|
|
336
|
+
*/
|
|
337
|
+
static decodeBinary(bytes) {
|
|
338
|
+
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
339
|
+
if (bytes.length < 17) {
|
|
340
|
+
throw new Error('TPSUID7RB: too short');
|
|
341
|
+
}
|
|
342
|
+
// MAGIC
|
|
343
|
+
if (bytes[0] !== 0x54 ||
|
|
344
|
+
bytes[1] !== 0x50 ||
|
|
345
|
+
bytes[2] !== 0x55 ||
|
|
346
|
+
bytes[3] !== 0x37) {
|
|
347
|
+
throw new Error('TPSUID7RB: bad magic');
|
|
348
|
+
}
|
|
349
|
+
// VERSION
|
|
350
|
+
const ver = bytes[4];
|
|
351
|
+
if (ver !== this.VER) {
|
|
352
|
+
throw new Error(`TPSUID7RB: unsupported version ${ver}`);
|
|
353
|
+
}
|
|
354
|
+
// FLAGS
|
|
355
|
+
const flags = bytes[5];
|
|
356
|
+
const compressed = (flags & 0x01) === 0x01;
|
|
357
|
+
// TIME (48-bit big-endian)
|
|
358
|
+
const epochMs = this.readU48(bytes, 6);
|
|
359
|
+
// NONCE (32-bit big-endian)
|
|
360
|
+
const nonce = ((bytes[12] << 24) >>> 0) +
|
|
361
|
+
((bytes[13] << 16) >>> 0) +
|
|
362
|
+
((bytes[14] << 8) >>> 0) +
|
|
363
|
+
bytes[15];
|
|
364
|
+
// LEN (varint at offset 16)
|
|
365
|
+
let offset = 16;
|
|
366
|
+
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
367
|
+
offset += bytesRead;
|
|
368
|
+
if (offset + tpsLen > bytes.length) {
|
|
369
|
+
throw new Error('TPSUID7RB: length overflow');
|
|
370
|
+
}
|
|
371
|
+
// TPS payload
|
|
372
|
+
const payload = bytes.slice(offset, offset + tpsLen);
|
|
373
|
+
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
374
|
+
const tps = new TextDecoder().decode(tpsUtf8);
|
|
375
|
+
return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Encode TPS to base64url string with prefix.
|
|
379
|
+
* This is the transport/storage form.
|
|
380
|
+
*
|
|
381
|
+
* @param tps - The TPS string to encode
|
|
382
|
+
* @param opts - Encoding options
|
|
383
|
+
* @returns Base64url encoded TPS-UID with prefix
|
|
384
|
+
*/
|
|
385
|
+
static encodeBinaryB64(tps, opts) {
|
|
386
|
+
const bytes = this.encodeBinary(tps, opts);
|
|
387
|
+
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Decode base64url string back to original TPS string.
|
|
391
|
+
*
|
|
392
|
+
* @param id - Base64url encoded TPS-UID with prefix
|
|
393
|
+
* @returns Decoded result with original TPS string
|
|
394
|
+
*/
|
|
395
|
+
static decodeBinaryB64(id) {
|
|
396
|
+
const s = id.trim();
|
|
397
|
+
if (!s.startsWith(this.PREFIX)) {
|
|
398
|
+
throw new Error('TPSUID7RB: missing prefix');
|
|
399
|
+
}
|
|
400
|
+
const b64 = s.slice(this.PREFIX.length);
|
|
401
|
+
const bytes = this.base64UrlDecode(b64);
|
|
402
|
+
return this.decodeBinary(bytes);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Validate base64url encoded TPS-UID format.
|
|
406
|
+
* Note: This validates shape only; binary decode is authoritative.
|
|
407
|
+
*
|
|
408
|
+
* @param id - String to validate
|
|
409
|
+
* @returns true if format is valid
|
|
410
|
+
*/
|
|
411
|
+
static validateBinaryB64(id) {
|
|
412
|
+
return this.REGEX.test(id.trim());
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Generate a TPS-UID from the current time and optional location.
|
|
416
|
+
*
|
|
417
|
+
* @param opts - Generation options
|
|
418
|
+
* @returns Base64url encoded TPS-UID
|
|
419
|
+
*/
|
|
420
|
+
static generate(opts) {
|
|
421
|
+
const now = new Date();
|
|
422
|
+
const tps = this.generateTPSString(now, opts);
|
|
423
|
+
return this.encodeBinaryB64(tps, {
|
|
424
|
+
compress: opts?.compress,
|
|
425
|
+
epochMs: now.getTime(),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// ---------------------------
|
|
429
|
+
// TPS String Helpers
|
|
430
|
+
// ---------------------------
|
|
431
|
+
/**
|
|
432
|
+
* Generate a TPS string from a Date and optional location.
|
|
433
|
+
*/
|
|
434
|
+
static generateTPSString(date, opts) {
|
|
435
|
+
const fullYear = date.getUTCFullYear();
|
|
436
|
+
const m = Math.floor(fullYear / 1000) + 1;
|
|
437
|
+
const c = Math.floor((fullYear % 1000) / 100) + 1;
|
|
438
|
+
const y = fullYear % 100;
|
|
439
|
+
const M = date.getUTCMonth() + 1;
|
|
440
|
+
const d = date.getUTCDate();
|
|
441
|
+
const h = date.getUTCHours();
|
|
442
|
+
const n = date.getUTCMinutes();
|
|
443
|
+
const s = date.getUTCSeconds();
|
|
444
|
+
const pad = (num) => num.toString().padStart(2, '0');
|
|
445
|
+
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)}`;
|
|
446
|
+
let spacePart = 'unknown';
|
|
447
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
448
|
+
spacePart = `${opts.latitude},${opts.longitude}`;
|
|
449
|
+
if (opts.altitude !== undefined) {
|
|
450
|
+
spacePart += `,${opts.altitude}m`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return `tps://${spacePart}@${timePart}`;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Parse epoch milliseconds from a TPS string.
|
|
457
|
+
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
458
|
+
*/
|
|
459
|
+
static epochMsFromTPSString(tps) {
|
|
460
|
+
let time;
|
|
461
|
+
if (tps.includes('@')) {
|
|
462
|
+
// URI format: tps://...@T:greg...
|
|
463
|
+
const at = tps.indexOf('@');
|
|
464
|
+
time = tps.slice(at + 1).trim();
|
|
465
|
+
}
|
|
466
|
+
else if (tps.startsWith('T:')) {
|
|
467
|
+
// Time-only format
|
|
468
|
+
time = tps;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
throw new Error('TPS: unrecognized format');
|
|
472
|
+
}
|
|
473
|
+
if (!time.startsWith('T:greg.')) {
|
|
474
|
+
throw new Error('TPS: only T:greg.* parsing is supported');
|
|
475
|
+
}
|
|
476
|
+
// Extract m (millennium), c (century), y (year)
|
|
477
|
+
const mMatch = time.match(/\.m(-?\d+)/);
|
|
478
|
+
const cMatch = time.match(/\.c(\d+)/);
|
|
479
|
+
const yMatch = time.match(/\.y(\d{1,4})/);
|
|
480
|
+
const MMatch = time.match(/\.M(\d{1,2})/);
|
|
481
|
+
const dMatch = time.match(/\.d(\d{1,2})/);
|
|
482
|
+
const hMatch = time.match(/\.h(\d{1,2})/);
|
|
483
|
+
const nMatch = time.match(/\.n(\d{1,2})/);
|
|
484
|
+
const sMatch = time.match(/\.s(\d{1,2})/);
|
|
485
|
+
// Calculate full year from millennium, century, year
|
|
486
|
+
let fullYear;
|
|
487
|
+
if (mMatch && cMatch && yMatch) {
|
|
488
|
+
const millennium = parseInt(mMatch[1], 10);
|
|
489
|
+
const century = parseInt(cMatch[1], 10);
|
|
490
|
+
const year = parseInt(yMatch[1], 10);
|
|
491
|
+
fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
492
|
+
}
|
|
493
|
+
else if (yMatch) {
|
|
494
|
+
// Fallback: interpret y as 2-digit year
|
|
495
|
+
let year = parseInt(yMatch[1], 10);
|
|
496
|
+
if (year < 100) {
|
|
497
|
+
year = year <= 69 ? 2000 + year : 1900 + year;
|
|
498
|
+
}
|
|
499
|
+
fullYear = year;
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
throw new Error('TPS: missing year component');
|
|
503
|
+
}
|
|
504
|
+
const month = MMatch ? parseInt(MMatch[1], 10) : 1;
|
|
505
|
+
const day = dMatch ? parseInt(dMatch[1], 10) : 1;
|
|
506
|
+
const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
|
|
507
|
+
const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
|
|
508
|
+
const second = sMatch ? parseInt(sMatch[1], 10) : 0;
|
|
509
|
+
const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
|
|
510
|
+
if (!Number.isFinite(epoch)) {
|
|
511
|
+
throw new Error('TPS: failed to compute epochMs');
|
|
512
|
+
}
|
|
513
|
+
return epoch;
|
|
514
|
+
}
|
|
515
|
+
// ---------------------------
|
|
516
|
+
// Binary Helpers
|
|
517
|
+
// ---------------------------
|
|
518
|
+
/** Write 48-bit unsigned integer (big-endian) */
|
|
519
|
+
static writeU48(epochMs) {
|
|
520
|
+
const b = new Uint8Array(6);
|
|
521
|
+
// Use BigInt for proper 48-bit handling
|
|
522
|
+
const v = BigInt(epochMs);
|
|
523
|
+
b[0] = Number((v >> 40n) & 0xffn);
|
|
524
|
+
b[1] = Number((v >> 32n) & 0xffn);
|
|
525
|
+
b[2] = Number((v >> 24n) & 0xffn);
|
|
526
|
+
b[3] = Number((v >> 16n) & 0xffn);
|
|
527
|
+
b[4] = Number((v >> 8n) & 0xffn);
|
|
528
|
+
b[5] = Number(v & 0xffn);
|
|
529
|
+
return b;
|
|
530
|
+
}
|
|
531
|
+
/** Read 48-bit unsigned integer (big-endian) */
|
|
532
|
+
static readU48(bytes, offset) {
|
|
533
|
+
const v = (BigInt(bytes[offset]) << 40n) +
|
|
534
|
+
(BigInt(bytes[offset + 1]) << 32n) +
|
|
535
|
+
(BigInt(bytes[offset + 2]) << 24n) +
|
|
536
|
+
(BigInt(bytes[offset + 3]) << 16n) +
|
|
537
|
+
(BigInt(bytes[offset + 4]) << 8n) +
|
|
538
|
+
BigInt(bytes[offset + 5]);
|
|
539
|
+
const n = Number(v);
|
|
540
|
+
if (!Number.isSafeInteger(n)) {
|
|
541
|
+
throw new Error('TPSUID7RB: u48 not safe integer');
|
|
542
|
+
}
|
|
543
|
+
return n;
|
|
544
|
+
}
|
|
545
|
+
/** Encode unsigned integer as LEB128 varint */
|
|
546
|
+
static uvarintEncode(n) {
|
|
547
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
548
|
+
throw new Error('uvarint must be non-negative int');
|
|
549
|
+
}
|
|
550
|
+
const out = [];
|
|
551
|
+
let x = n >>> 0;
|
|
552
|
+
while (x >= 0x80) {
|
|
553
|
+
out.push((x & 0x7f) | 0x80);
|
|
554
|
+
x >>>= 7;
|
|
555
|
+
}
|
|
556
|
+
out.push(x);
|
|
557
|
+
return new Uint8Array(out);
|
|
558
|
+
}
|
|
559
|
+
/** Decode LEB128 varint */
|
|
560
|
+
static uvarintDecode(bytes, offset) {
|
|
561
|
+
let x = 0;
|
|
562
|
+
let s = 0;
|
|
563
|
+
let i = 0;
|
|
564
|
+
while (true) {
|
|
565
|
+
if (offset + i >= bytes.length) {
|
|
566
|
+
throw new Error('uvarint overflow');
|
|
567
|
+
}
|
|
568
|
+
const b = bytes[offset + i];
|
|
569
|
+
if (b < 0x80) {
|
|
570
|
+
if (i > 9 || (i === 9 && b > 1)) {
|
|
571
|
+
throw new Error('uvarint too large');
|
|
572
|
+
}
|
|
573
|
+
x |= b << s;
|
|
574
|
+
return { value: x >>> 0, bytesRead: i + 1 };
|
|
575
|
+
}
|
|
576
|
+
x |= (b & 0x7f) << s;
|
|
577
|
+
s += 7;
|
|
578
|
+
i++;
|
|
579
|
+
if (i > 10) {
|
|
580
|
+
throw new Error('uvarint too long');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ---------------------------
|
|
585
|
+
// Base64url Helpers
|
|
586
|
+
// ---------------------------
|
|
587
|
+
/** Encode bytes to base64url (no padding) */
|
|
588
|
+
static base64UrlEncode(bytes) {
|
|
589
|
+
// Node.js environment
|
|
590
|
+
if (typeof Buffer !== 'undefined') {
|
|
591
|
+
return Buffer.from(bytes)
|
|
592
|
+
.toString('base64')
|
|
593
|
+
.replace(/\+/g, '-')
|
|
594
|
+
.replace(/\//g, '_')
|
|
595
|
+
.replace(/=+$/g, '');
|
|
596
|
+
}
|
|
597
|
+
// Browser environment
|
|
598
|
+
let binary = '';
|
|
599
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
600
|
+
binary += String.fromCharCode(bytes[i]);
|
|
601
|
+
}
|
|
602
|
+
return btoa(binary)
|
|
603
|
+
.replace(/\+/g, '-')
|
|
604
|
+
.replace(/\//g, '_')
|
|
605
|
+
.replace(/=+$/g, '');
|
|
606
|
+
}
|
|
607
|
+
/** Decode base64url to bytes */
|
|
608
|
+
static base64UrlDecode(b64url) {
|
|
609
|
+
// Add padding
|
|
610
|
+
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
611
|
+
const b64 = (b64url + '='.repeat(padLen))
|
|
612
|
+
.replace(/-/g, '+')
|
|
613
|
+
.replace(/_/g, '/');
|
|
614
|
+
// Node.js environment
|
|
615
|
+
if (typeof Buffer !== 'undefined') {
|
|
616
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
617
|
+
}
|
|
618
|
+
// Browser environment
|
|
619
|
+
const binary = atob(b64);
|
|
620
|
+
const bytes = new Uint8Array(binary.length);
|
|
621
|
+
for (let i = 0; i < binary.length; i++) {
|
|
622
|
+
bytes[i] = binary.charCodeAt(i);
|
|
623
|
+
}
|
|
624
|
+
return bytes;
|
|
625
|
+
}
|
|
626
|
+
// ---------------------------
|
|
627
|
+
// Compression Helpers
|
|
628
|
+
// ---------------------------
|
|
629
|
+
/** Compress using zlib deflate raw */
|
|
630
|
+
static deflateRaw(data) {
|
|
631
|
+
// Node.js environment
|
|
632
|
+
if (typeof require !== 'undefined') {
|
|
633
|
+
try {
|
|
634
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
635
|
+
const zlib = require('zlib');
|
|
636
|
+
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
throw new Error('TPSUID7RB: compression not available');
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Browser: would need pako or similar library
|
|
643
|
+
throw new Error('TPSUID7RB: compression not available in browser');
|
|
644
|
+
}
|
|
645
|
+
/** Decompress using zlib inflate raw */
|
|
646
|
+
static inflateRaw(data) {
|
|
647
|
+
// Node.js environment
|
|
648
|
+
if (typeof require !== 'undefined') {
|
|
649
|
+
try {
|
|
650
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
651
|
+
const zlib = require('zlib');
|
|
652
|
+
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
throw new Error('TPSUID7RB: decompression failed');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Browser: would need pako or similar library
|
|
659
|
+
throw new Error('TPSUID7RB: decompression not available in browser');
|
|
660
|
+
}
|
|
661
|
+
// ---------------------------
|
|
662
|
+
// Random Bytes
|
|
663
|
+
// ---------------------------
|
|
664
|
+
/** Generate cryptographically secure random bytes */
|
|
665
|
+
static randomBytes(length) {
|
|
666
|
+
// Node.js environment
|
|
667
|
+
if (typeof require !== 'undefined') {
|
|
668
|
+
try {
|
|
669
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
670
|
+
const crypto = require('crypto');
|
|
671
|
+
return new Uint8Array(crypto.randomBytes(length));
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
// Fallback to crypto.getRandomValues
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Browser or fallback
|
|
678
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
679
|
+
const bytes = new Uint8Array(length);
|
|
680
|
+
crypto.getRandomValues(bytes);
|
|
681
|
+
return bytes;
|
|
682
|
+
}
|
|
683
|
+
throw new Error('TPSUID7RB: no crypto available');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Magic bytes: "TPU7" */
|
|
687
|
+
TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
688
|
+
/** Version 1 */
|
|
689
|
+
TPSUID7RB.VER = 0x01;
|
|
690
|
+
/** String prefix for base64url encoded form */
|
|
691
|
+
TPSUID7RB.PREFIX = 'tpsuid7rb_';
|
|
692
|
+
/** Regex for validating base64url encoded form */
|
|
693
|
+
TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|