diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index bf2bbb7d4..0e34b1b14 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -109,6 +109,9 @@ describe('whoami extension — handleWhoami', () => { expect(body.oidc_only).toBe(false); // is_user_token is present (true) for user actors. expect(body.is_user_token).toBe(true); + // Account creation time, in unix seconds. + expect(typeof body.created_ts).toBe('number'); + expect(body.created_ts).toBeGreaterThan(0); // `directories` is only sent to user actors — confirm it’s present. expect(body.directories).toBeDefined(); // taskbar_items is only sent to user actors. @@ -132,7 +135,7 @@ describe('whoami extension — handleWhoami', () => { expect(flags.payment_bypass).toBeUndefined(); }); - it('strips desktop_bg_* and human_readable_age fields for app actors', async () => { + it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => { const user = await seedUser(); const { res, captured } = makeRes(); @@ -154,6 +157,8 @@ describe('whoami extension — handleWhoami', () => { expect(body.desktop_bg_color).toBeUndefined(); expect(body.desktop_bg_fit).toBeUndefined(); expect(body.human_readable_age).toBeUndefined(); + // Account age, in either form, is not exposed to apps. + expect(body.created_ts).toBeUndefined(); // Directories are user-only. expect(body.directories).toBeUndefined(); }); diff --git a/extensions/whoami.ts b/extensions/whoami.ts index b2e52e8af..81eedb675 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -14,6 +14,15 @@ const timeago = (() => { return new TimeAgo('en-US'); })(); +// User timestamps come off the DB as SQL datetime strings; the wire format +// for all of them is unix seconds. Unparseable values are dropped rather +// than sent as NaN. +const toUnixSeconds = (value: unknown): number | undefined => { + if (!value) return undefined; + const ms = new Date(value as string | number | Date).getTime(); + return Number.isNaN(ms) ? undefined : Math.round(ms / 1000); +}; + // Allowlist of `config.feature_flags` keys safe to surface via /whoami. // Anything not listed here stays server-side, so internal flags // (payment_bypass, staff_only_*, etc.) cannot leak by accident. Add a @@ -98,6 +107,7 @@ export const handleWhoami = async ( : undefined, otp: !!user.otp_enabled, feature_flags, + created_ts: toUnixSeconds(user.timestamp), human_readable_age: user.timestamp ? timeago.format(new Date(user.timestamp as string)) : null, @@ -141,14 +151,9 @@ export const handleWhoami = async ( } // Last activity - if (user.last_activity_ts) { - try { - details.last_activity_ts = Math.round( - new Date(user.last_activity_ts as string).getTime() / 1000, - ); - } catch { - /* ignore parse error */ - } + const lastActivityTs = toUnixSeconds(user.last_activity_ts); + if (lastActivityTs !== undefined) { + details.last_activity_ts = lastActivityTs; } // Strip sensitive fields for app actors @@ -164,6 +169,7 @@ export const handleWhoami = async ( delete details.desktop_bg_color; delete details.desktop_bg_fit; delete details.human_readable_age; + delete details.created_ts; delete details.is_user_token; delete details.metadata; } @@ -183,8 +189,7 @@ export const handleWhoami = async ( } const subscription = details.subscription as - | { offering?: Record } - | undefined; + { offering?: Record } | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/docs/src/Objects/user.md b/src/docs/src/Objects/user.md index 98ea8bb15..2a171c946 100644 --- a/src/docs/src/Objects/user.md +++ b/src/docs/src/Objects/user.md @@ -27,6 +27,10 @@ A number value containing the user's free storage. A string containing the current active app. +#### `created_ts` (Number) + +A number value indicating when the user's account was created, in seconds since the Unix epoch. Only returned to user tokens; apps acting on a user's behalf do not receive it. + #### `is_temp` (Boolean) A boolean value indicating whether the user's account is temporary. diff --git a/src/puter-js/tests/api/suites/os.suite.ts b/src/puter-js/tests/api/suites/os.suite.ts index 6c756c057..16f9a4283 100644 --- a/src/puter-js/tests/api/suites/os.suite.ts +++ b/src/puter-js/tests/api/suites/os.suite.ts @@ -5,6 +5,11 @@ export default suite('os', { const user = await t.puter.os.user(); t.assert.ok(user && typeof user === 'object', 'user should be an object'); t.assert.equal(user.username, t.env.users.user.username); + t.assert.equal( + typeof user.created_ts, + 'number', + 'created_ts should be unix seconds', + ); }, 'user accepts trailing success/error callbacks': async (t) => { diff --git a/src/puter-js/types/modules/auth.d.ts b/src/puter-js/types/modules/auth.d.ts index 6174a5ffa..6a29582a8 100644 --- a/src/puter-js/types/modules/auth.d.ts +++ b/src/puter-js/types/modules/auth.d.ts @@ -12,6 +12,11 @@ export interface User { actual_free_storage?: number; /** The current active app. */ app_name?: string; + /** + * When the account was created, in unix seconds. Only returned to user + * tokens — apps acting on a user's behalf do not receive it. + */ + created_ts?: number; feature_flags?: Record; hasDevAccountAccess?: boolean; /** Whether the user's account is temporary. */