Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions skills/partiful-events/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <event-id>
Expand Down
23 changes: 9 additions & 14 deletions src/commands/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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) {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions src/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,53 @@ export function validateImageOptions(...imageOpts) {
return count;
}

/**
* Canonical public URL for an event's Partiful page.
* Single source of truth for the `partiful.com/e/<id>` 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
* 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: buildEventUrl(e.id),
};
}

/**
* Convert a plain JS object to Firestore field format (recursive).
*/
Expand Down
77 changes: 77 additions & 0 deletions tests/jwt-identity.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading