From 244da3892725e06998b6838f2fcf2d8141b22ad2 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Sun, 5 Jul 2026 14:00:35 -0700 Subject: [PATCH 1/2] feat(events): expose personal RSVP (myRsvp) in events list; fix isHost `events list` dropped the per-event `guest` object returned by the Partiful home-page endpoints, so a caller could not see their own RSVP. It also computed `isHost` from `config.userId`, which is undefined in most auth.json files, making `isHost` always false. Both stem from not resolving "who am I". Fix: - auth.js: add decodeJwtPayload() + getUserIdFromToken() to read the user id from the Firebase token (user_id, falling back to sub). Backfill config.userId on token refresh so it self-heals for older auth.json files. - events.js (lib): extract a pure, unit-testable mapEventSummary(e, me) that adds `myRsvp` (e.guest.status: GOING|MAYBE|DECLINED|SENT, null when hosting) and fixes `isHost` to check ownerIds against the resolved id. - events.js (command): resolve `me` from config.userId, falling back to decoding the token (covers the PARTIFUL_TOKEN env path), then map via the helper. Additive and backward-compatible: existing fields keep their names, order, and defaults. Verified against a live account (myRsvp populated across GOING/MAYBE/DECLINED/SENT; isHost true only for owned events). Docs: README JSON output example, partiful-events skill, AGENTS.md. Tests: 21 new unit tests (jwt-identity, map-event-summary); suite 106 -> 127. Closes #56 --- AGENTS.md | 2 + README.md | 21 ++++++++ package-lock.json | 7 ++- package.json | 2 +- skills/partiful-events/SKILL.md | 13 +++++ src/commands/events.js | 23 ++++---- src/lib/auth.js | 46 ++++++++++++++++ src/lib/events.js | 36 +++++++++++++ tests/jwt-identity.test.js | 77 ++++++++++++++++++++++++++ tests/map-event-summary.test.js | 96 +++++++++++++++++++++++++++++++++ 10 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 tests/jwt-identity.test.js create mode 100644 tests/map-event-summary.test.js diff --git a/AGENTS.md b/AGENTS.md index 4fc1a83..e44b734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ There is no `--name` flag on `guests invite`. You must resolve names to user IDs ### Auth: userId can be null and things still work `partiful doctor` may flag `userId: null`. The CLI authenticates via Firebase token, not userId — most operations work fine. Don't treat this as a blocking error. +On the next token refresh the CLI now **backfills `userId` from the token** automatically (decoded from the Firebase JWT), so `myRsvp` and `isHost` on `events list` are correct even for older `auth.json` files that predate userId capture. No re-login needed. + ### Destructive commands require confirmation `events cancel` and `blasts send` prompt for confirmation before executing. Pass `-y` or `--yes` to skip in automated/agent flows. Use `--dry-run` on any command to preview what would happen without side effects. diff --git a/README.md b/README.md index dd3c81f..4d43169 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,27 @@ All commands support `--format json`. Responses follow a consistent envelope: } ``` +`events list` returns one summary object per event. Each includes your **own** RSVP and host status: + +```json +{ + "id": "BiwCtA9kRMh8Od5TvuPq", + "title": "Skills & Drills", + "startDate": "2026-07-08T01:30:00.000Z", + "endDate": null, + "location": null, + "status": "PUBLISHED", + "isHost": false, + "myRsvp": "GOING", + "going": 12, + "maybe": 14, + "url": "https://partiful.com/e/BiwCtA9kRMh8Od5TvuPq" +} +``` + +- **`myRsvp`** — your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, no reply yet), or `null` on events you host. Filter on this to sync only the events you've accepted, e.g. `partiful events list | jq '.data[] | select(.myRsvp == "GOING")'`. +- **`isHost`** — `true` for events you own. (`going`/`maybe` are aggregate counts across all guests, not your status.) + Errors return `{ "status": "error", "error": { "code": 1, "type": "api_error", "message": "..." } }`. Exit codes: `0` success · `1` API error · `2` auth error · `3` validation · `4` not found · `5` internal. diff --git a/package-lock.json b/package-lock.json index 2a1dc25..bc94d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "partiful-cli", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "partiful-cli", - "version": "2.0.0", + "version": "2.1.0", "license": "MIT", "dependencies": { "commander": "^13.0.0", @@ -17,6 +17,9 @@ }, "devDependencies": { "vitest": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 1a77d56..57fcd36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "partiful-cli", - "version": "2.0.0", + "version": "2.1.0", "description": "CLI for creating and managing Partiful events via API — JSON-first, agent-friendly", "type": "module", "bin": { diff --git a/skills/partiful-events/SKILL.md b/skills/partiful-events/SKILL.md index 3196f95..827f022 100644 --- a/skills/partiful-events/SKILL.md +++ b/skills/partiful-events/SKILL.md @@ -14,6 +14,19 @@ partiful events list --past partiful events list --past --include-cancelled ``` +Each event in the list includes **your own** RSVP and host status: + +| Field | Meaning | +|-------|---------| +| `myRsvp` | Your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, not yet answered), or `null` on events you host | +| `isHost` | `true` when you own the event | +| `going` / `maybe` | Aggregate guest counts (everyone), **not** your status | + +```bash +# Only the events you've said yes to (e.g. to sync to a calendar) +partiful events list | jq '.data[] | select(.myRsvp == "GOING")' +``` + ### Get Event Details ```bash partiful events get diff --git a/src/commands/events.js b/src/commands/events.js index 5b6d1cf..0897ae1 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -2,7 +2,7 @@ * Events commands: list, get, create, update, cancel */ -import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import { loadConfig, getValidToken, wrapPayload, getUserIdFromToken } from '../lib/auth.js'; import { resolveCohostNames } from '../lib/cohosts.js'; import { fetchCatalog, searchPosters, buildPosterImage } from '../lib/posters.js'; import { apiRequest, firestoreRequest } from '../lib/http.js'; @@ -12,7 +12,7 @@ import { PartifulError } from '../lib/errors.js'; import { confirm, buildBaseEvent, buildLinks, toFirestoreMap, validateImageOptions, resolvePosterImage, resolveUploadImage, - isUrl, ALLOWED_IMAGE_EXTENSIONS, + isUrl, ALLOWED_IMAGE_EXTENSIONS, mapEventSummary, } from '../lib/events.js'; /** @@ -71,18 +71,13 @@ export function registerEventsCommands(program) { eventList = eventList.filter(e => e.status !== 'CANCELED'); } - const mapped = (eventList || []).map(e => ({ - id: e.id, - title: e.title, - startDate: e.startDate, - endDate: e.endDate || null, - location: e.location || null, - status: e.status, - isHost: e.ownerIds?.includes(config.userId) || false, - going: e.guestStatusCounts?.GOING || 0, - maybe: e.guestStatusCounts?.MAYBE || 0, - url: `https://partiful.com/e/${e.id}`, - })); + // Identify the authenticated user so we can surface their own RSVP + // (myRsvp) and host status. config.userId is backfilled on token + // refresh, but fall back to decoding the token directly for the + // PARTIFUL_TOKEN env path where config.userId is never set. + const me = config.userId || getUserIdFromToken(token); + + const mapped = (eventList || []).map(e => mapEventSummary(e, me)); jsonOutput(mapped, { count: mapped.length, type: opts.past ? 'past' : 'upcoming' }); } catch (e) { diff --git a/src/lib/auth.js b/src/lib/auth.js index ac321fb..05aec80 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -71,10 +71,56 @@ export async function getValidToken(config) { config.refreshToken = result.refresh_token; } + // Self-heal: older auth.json files predate userId capture. Backfill it from + // the token here (the refresh path already persists config below), so + // host detection and any userId-dependent payloads work without re-login. + // Note: the env-var token path returns early above and never writes to disk. + if (!config.userId) { + const uid = getUserIdFromToken(config.accessToken); + if (uid) config.userId = uid; + } + saveConfig(config); return config.accessToken; } +/** + * Decode the (unverified) payload of a Firebase JWT. + * + * The CLI does not need to verify the signature — the token was already issued + * to us by Firebase and is only decoded to read identity claims. Returns null + * for anything that is not a well-formed three-segment JWT. + * + * @param {string} token JWT string (header.payload.signature). + * @returns {object|null} Decoded payload object, or null on any parse failure. + */ +export function decodeJwtPayload(token) { + if (typeof token !== 'string') return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + // JWTs use base64url; normalise to base64 before decoding. + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); + } catch { + return null; + } +} + +/** + * Extract the authenticated user's Partiful user ID from a Firebase token. + * Firebase ID tokens carry the UID in both `user_id` and the standard `sub` + * claim; we prefer `user_id` and fall back to `sub`. + * + * @param {string} token Firebase JWT. + * @returns {string|null} The user ID, or null if it cannot be determined. + */ +export function getUserIdFromToken(token) { + const payload = decodeJwtPayload(token); + if (!payload || typeof payload !== 'object') return null; + return payload.user_id || payload.sub || null; +} + export function wrapPayload(config, params = {}) { return { ...params, diff --git a/src/lib/events.js b/src/lib/events.js index fe52799..fbac37d 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -172,6 +172,42 @@ export function validateImageOptions(...imageOpts) { return count; } +/** + * Map a raw event object from the Partiful home-page endpoints + * (getMyUpcomingEventsForHomePage / getMyPastEventsForHomePage) into the + * compact summary shape returned by `events list`. + * + * Pure function — no I/O — so it can be unit-tested against fixtures. + * + * Two personal fields are derived from the caller's identity (`me`, the + * authenticated Partiful user ID): + * - `myRsvp`: the caller's own RSVP for the event. Present on events the + * caller was invited to as `e.guest.status` + * (GOING | MAYBE | DECLINED | SENT). Null when the caller hosts the event + * (no guest record) or when the field is absent. + * - `isHost`: whether the caller owns the event, i.e. their ID is in + * `e.ownerIds`. Falls back to false when `me` is unknown. + * + * @param {object} e Raw event object from the API. + * @param {string|null} me Authenticated user's Partiful user ID. + * @returns {object} Summary object with a stable field order. + */ +export function mapEventSummary(e, me) { + return { + id: e.id, + title: e.title, + startDate: e.startDate, + endDate: e.endDate || null, + location: e.location || null, + status: e.status, + isHost: (me != null && e.ownerIds?.includes(me)) || false, + myRsvp: e.guest?.status ?? null, + going: e.guestStatusCounts?.GOING || 0, + maybe: e.guestStatusCounts?.MAYBE || 0, + url: `https://partiful.com/e/${e.id}`, + }; +} + /** * Convert a plain JS object to Firestore field format (recursive). */ diff --git a/tests/jwt-identity.test.js b/tests/jwt-identity.test.js new file mode 100644 index 0000000..2171857 --- /dev/null +++ b/tests/jwt-identity.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { decodeJwtPayload, getUserIdFromToken } from '../src/lib/auth.js'; + +/** + * Build an unsigned JWT (header.payload.signature) for testing decode logic. + * The signature is irrelevant — we never verify it — so a placeholder is fine. + */ +function makeJwt(payload) { + const b64url = (obj) => + Buffer.from(JSON.stringify(obj)).toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `${b64url({ alg: 'RS256', typ: 'JWT' })}.${b64url(payload)}.sig`; +} + +describe('decodeJwtPayload', () => { + it('decodes a well-formed JWT payload', () => { + const token = makeJwt({ user_id: 'abc123', sub: 'abc123' }); + expect(decodeJwtPayload(token)).toEqual({ user_id: 'abc123', sub: 'abc123' }); + }); + + it('decodes base64url payloads containing - and _ chars', () => { + // A payload that base64-encodes with + and / (→ - and _ in base64url). + const payload = { user_id: 'a>b?c>d?', note: 'ok' }; + const token = makeJwt(payload); + expect(decodeJwtPayload(token)).toEqual(payload); + }); + + it('returns null for a non-string token', () => { + expect(decodeJwtPayload(null)).toBeNull(); + expect(decodeJwtPayload(undefined)).toBeNull(); + expect(decodeJwtPayload(12345)).toBeNull(); + }); + + it('returns null when the token is not three segments', () => { + expect(decodeJwtPayload('not-a-jwt')).toBeNull(); + expect(decodeJwtPayload('only.two')).toBeNull(); + expect(decodeJwtPayload('a.b.c.d')).toBeNull(); + }); + + it('returns null when the payload segment is not valid JSON', () => { + const garbage = `${Buffer.from('x').toString('base64')}.@@@notbase64@@@.sig`; + expect(decodeJwtPayload(garbage)).toBeNull(); + }); +}); + +describe('getUserIdFromToken', () => { + it('prefers the user_id claim', () => { + const token = makeJwt({ user_id: 'from-user-id', sub: 'from-sub' }); + expect(getUserIdFromToken(token)).toBe('from-user-id'); + }); + + it('falls back to the sub claim when user_id is absent', () => { + const token = makeJwt({ sub: 'from-sub' }); + expect(getUserIdFromToken(token)).toBe('from-sub'); + }); + + it('returns null when neither claim is present', () => { + const token = makeJwt({ email: 'x@example.com' }); + expect(getUserIdFromToken(token)).toBeNull(); + }); + + it('returns null for an undecodable token instead of throwing', () => { + expect(getUserIdFromToken('garbage')).toBeNull(); + expect(getUserIdFromToken(null)).toBeNull(); + }); + + it('returns null when the payload decodes to a non-object primitive', () => { + // Guards against reading .sub off a string (String.prototype.sub is a + // real legacy function, so a bare typeof check is not enough). + const b64url = (s) => Buffer.from(s).toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const stringPayload = `${b64url('{}')}.${b64url('"hello"')}.sig`; + const numberPayload = `${b64url('{}')}.${b64url('42')}.sig`; + expect(getUserIdFromToken(stringPayload)).toBeNull(); + expect(getUserIdFromToken(numberPayload)).toBeNull(); + }); +}); diff --git a/tests/map-event-summary.test.js b/tests/map-event-summary.test.js new file mode 100644 index 0000000..74df156 --- /dev/null +++ b/tests/map-event-summary.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { mapEventSummary } from '../src/lib/events.js'; + +const ME = 'eBhI7Kx0hDTVW56uZHO519Ifm452'; + +// Minimal raw event as returned by getMyUpcomingEventsForHomePage. +function rawEvent(overrides = {}) { + return { + id: 'evt1', + title: 'Test Event', + startDate: '2026-07-08T01:30:00.000Z', + status: 'PUBLISHED', + guestStatusCounts: { GOING: 12, MAYBE: 14 }, + ownerIds: ['someOtherHost'], + ...overrides, + }; +} + +describe('mapEventSummary — myRsvp', () => { + it.each(['GOING', 'MAYBE', 'DECLINED', 'SENT'])( + 'surfaces my own RSVP status "%s" from the guest record', + (status) => { + const e = rawEvent({ guest: { userId: ME, status } }); + expect(mapEventSummary(e, ME).myRsvp).toBe(status); + } + ); + + it('is null when there is no guest record (e.g. events I host)', () => { + const e = rawEvent({ ownerIds: [ME] }); // no guest field + expect(mapEventSummary(e, ME).myRsvp).toBeNull(); + }); + + it('is null when guest exists but has no status', () => { + const e = rawEvent({ guest: { userId: ME } }); + expect(mapEventSummary(e, ME).myRsvp).toBeNull(); + }); +}); + +describe('mapEventSummary — isHost', () => { + it('is true when my id is in ownerIds', () => { + const e = rawEvent({ ownerIds: ['x', ME, 'y'] }); + expect(mapEventSummary(e, ME).isHost).toBe(true); + }); + + it('is false when I am only a guest', () => { + const e = rawEvent({ ownerIds: ['someOtherHost'], guest: { userId: ME, status: 'GOING' } }); + expect(mapEventSummary(e, ME).isHost).toBe(false); + }); + + it('is false (not a crash) when me is null — the pre-fix broken state', () => { + const e = rawEvent({ ownerIds: [ME] }); + expect(mapEventSummary(e, null).isHost).toBe(false); + }); + + it('is false when me is undefined (userId never resolved)', () => { + const e = rawEvent({ ownerIds: [ME] }); + expect(mapEventSummary(e, undefined).isHost).toBe(false); + }); + + it('is false when ownerIds is missing entirely', () => { + const e = rawEvent({ ownerIds: undefined }); + expect(mapEventSummary(e, ME).isHost).toBe(false); + }); +}); + +describe('mapEventSummary — backward compatibility', () => { + it('preserves all pre-existing fields and values', () => { + const e = rawEvent({ + endDate: '2026-07-08T03:30:00.000Z', + location: 'The Park', + guest: { userId: ME, status: 'MAYBE' }, + }); + const out = mapEventSummary(e, ME); + expect(out).toEqual({ + id: 'evt1', + title: 'Test Event', + startDate: '2026-07-08T01:30:00.000Z', + endDate: '2026-07-08T03:30:00.000Z', + location: 'The Park', + status: 'PUBLISHED', + isHost: false, + myRsvp: 'MAYBE', + going: 12, + maybe: 14, + url: 'https://partiful.com/e/evt1', + }); + }); + + it('defaults counts, endDate, and location the same way as before', () => { + const out = mapEventSummary(rawEvent({ guestStatusCounts: undefined }), ME); + expect(out.going).toBe(0); + expect(out.maybe).toBe(0); + expect(out.endDate).toBeNull(); + expect(out.location).toBeNull(); + }); +}); From 53db0122aa7423d65d9559c9d98a9736fb9abfb6 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Sun, 5 Jul 2026 14:28:45 -0700 Subject: [PATCH 2/2] refactor(events): extract buildEventUrl helper (CodeRabbit nit) CodeRabbit flagged the duplicated `https://partiful.com/e/${id}` template. Add a canonical buildEventUrl(id) in src/lib/events.js and use it in mapEventSummary; add unit tests. Pre-existing duplicate call sites in other commands are left for a dedicated cleanup to keep this PR scoped. --- src/lib/events.js | 13 ++++++++++++- tests/map-event-summary.test.js | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/events.js b/src/lib/events.js index fbac37d..825939d 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -172,6 +172,17 @@ export function validateImageOptions(...imageOpts) { return count; } +/** + * Canonical public URL for an event's Partiful page. + * Single source of truth for the `partiful.com/e/` format. + * + * @param {string} id Event ID. + * @returns {string} The event's public URL. + */ +export function buildEventUrl(id) { + return `https://partiful.com/e/${id}`; +} + /** * Map a raw event object from the Partiful home-page endpoints * (getMyUpcomingEventsForHomePage / getMyPastEventsForHomePage) into the @@ -204,7 +215,7 @@ export function mapEventSummary(e, me) { myRsvp: e.guest?.status ?? null, going: e.guestStatusCounts?.GOING || 0, maybe: e.guestStatusCounts?.MAYBE || 0, - url: `https://partiful.com/e/${e.id}`, + url: buildEventUrl(e.id), }; } diff --git a/tests/map-event-summary.test.js b/tests/map-event-summary.test.js index 74df156..1c05adf 100644 --- a/tests/map-event-summary.test.js +++ b/tests/map-event-summary.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mapEventSummary } from '../src/lib/events.js'; +import { mapEventSummary, buildEventUrl } from '../src/lib/events.js'; const ME = 'eBhI7Kx0hDTVW56uZHO519Ifm452'; @@ -16,6 +16,17 @@ function rawEvent(overrides = {}) { }; } +describe('buildEventUrl', () => { + it('builds the canonical Partiful event URL', () => { + expect(buildEventUrl('abc123')).toBe('https://partiful.com/e/abc123'); + }); + + it('is the single source used by mapEventSummary', () => { + const summary = mapEventSummary({ id: 'zzz', guestStatusCounts: {} }, null); + expect(summary.url).toBe(buildEventUrl('zzz')); + }); +}); + describe('mapEventSummary — myRsvp', () => { it.each(['GOING', 'MAYBE', 'DECLINED', 'SENT'])( 'surfaces my own RSVP status "%s" from the guest record',