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