openvibers/openvibe.network Commits

JSON times carry their zone (ISO 8601 Z) on the API edge (#870)

OP OpenVibers committed 24 h ago · 137ea75e02 · 3 files, +107 −0

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 4f915d28..db44e85a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -44,6 +44,8 @@ const { signToken } = require('./auth/routes');
const ready = (async () => {
const app = express();
++// Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.
++app.use(require('./iso-times').isoJsonTimes);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
ddiff --git a/server/iso-times.js b/server/iso-times.js
nnew file mode 100644
iindex 00000000..9c16da79
---- /dev/null
++++ b/server/iso-times.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Times leave with their zone (openvibe.design rule 148).
++ *
++ * The tables created from SQLite (migrations/0001_initial.sql) keep timestamps as text in the
++ * SQLite shape `YYYY-MM-DD HH:MM:SS`, which ov_now() writes in UTC. A browser reads that string as
++ * local time, so west of UTC every event looked hours in the future. This is the edge: JSON fields
++ * whose name says they are a time are rendered as ISO 8601 with Z, and nothing else changes — the
++ * stored text, cursors built from it and every query that compares it stay exactly as they are.
++ *
++ * Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.
++ * `POST /api/v1/...` cursors echo their own stored text, not one of these fields, so pagination is
++ * unaffected.
++ */
++const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;
++// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …
++// and the bare `created` / `updated` / `started` / `ended` / `expires` some responses use.
++const TIME_KEY = /(^|_)(at|created|updated|started|ended|expires|timestamp)$|^last_|^(since|until)$/;
++
++/** One stored time as ISO 8601 with Z; anything not in the SQLite shape is returned untouched. */
++function isoTime(value) {
++ return typeof value === 'string' && SQL_TEXT_TIME.test(value) ? `${value.replace(' ', 'T')}Z` : value;
++}
++
++/** Copy `value`, rendering time-named fields; arrays and plain objects are walked, a Date is not. */
++function normalizeTimes(value, depth = 0) {
++ if (depth > 8 || value === null || typeof value !== 'object') return value;
++ if (Array.isArray(value)) return value.map((item) => normalizeTimes(item, depth + 1));
++ const proto = Object.getPrototypeOf(value);
++ if (proto !== Object.prototype && proto !== null) return value;
++ const out = {};
++ for (const [key, item] of Object.entries(value)) {
++ out[key] = TIME_KEY.test(key) ? isoTime(item) : normalizeTimes(item, depth + 1);
++ }
++ return out;
++}
++
++/** Express middleware: JSON answers carry ISO times. Internal service routes keep their SQL shape. */
++function isoJsonTimes(req, res, next) {
++ if (req.path.startsWith('/internal/')) return next();
++ const json = res.json.bind(res);
++ res.json = (body) => json(normalizeTimes(body));
++ next();
++}
++
++module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };
ddiff --git a/test/iso-times.test.js b/test/iso-times.test.js
nnew file mode 100644
iindex 00000000..0c0eacec
---- /dev/null
++++ b/test/iso-times.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Times leave with their zone (openvibe.design rule 148): Network's SQLite-era text timestamps are UTC
++ * but a browser reads `YYYY-MM-DD HH:MM:SS` as local time. The JSON edge renders time fields as ISO
++ * 8601 with Z; stored text, cursors and every query that compares it stay unchanged.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { isoTime, normalizeTimes, isoJsonTimes } = require('../server/iso-times');
++
++assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');
++assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');
++assert.strictEqual(isoTime('2026-11-12T05:01:02+02:00'), '2026-11-12T05:01:02+02:00', 'ISO with an offset is left alone');
++assert.strictEqual(isoTime('2026-11-12'), '2026-11-12', 'a date is not a time');
++assert.strictEqual(isoTime(null), null);
++assert.deepStrictEqual(
++ normalizeTimes({ id: 1, created_at: '2026-11-12 05:01:02', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12 05:01:02' }, tags: [{ expires_at: '2026-11-12 05:01:02' }] }),
++ { id: 1, created_at: '2026-11-12T05:01:02Z', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12T05:01:02Z' }, tags: [{ expires_at: '2026-11-12T05:01:02Z' }] },
++ 'time-named fields are rendered; body text that looks like a time is not',
++);
++
++(async () => {
++ const log = console.log; console.log = () => {};
++ const db = getDb();
++ console.log = log;
++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (41, 'zone', 'x', 'user') ON CONFLICT DO NOTHING").run();
++ for (const [id, name] of [['vibe', 'Vibe'], ['cloud', 'Cloud']]) {
++ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES (?, ?, ?, 'dark', '{}', 1, 1) ON CONFLICT DO NOTHING").run(id, name, id);
++ }
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ const { signToken } = require('../server/auth/routes');
++ const app = express();
++ app.use(express.json());
++ app.use(isoJsonTimes);
++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ app.use('/api/themes', require('../server/themes/routes'));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = await signToken(await db.prepare('SELECT * FROM users WHERE id = 41').get(), keys.privateKey, config);
++ const call = async (method, p, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++ await call('PUT', '/api/themes/me', { theme_id: 'vibe' });
++ await call('PUT', '/api/themes/me', { theme_id: 'cloud' });
++ const r = await call('GET', '/api/themes/me/history');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.ok(r.body.history.length > 0, 'the history has the replaced setting');
++ for (const entry of r.body.history) assert.match(entry.created_at, /Z$/, 'theme history created_at is ISO with Z');
++ const row = await db.prepare('SELECT created_at FROM user_theme_history WHERE user_id = 41 ORDER BY id DESC LIMIT 1').get();
++ assert.match(row.created_at, /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/, 'the stored text is untouched');
++
++ await new Promise((r2) => server.close(r2));
++ console.log('iso times: helper and GET /api/themes/me/history checked');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });