Skip to content
Open
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
104 changes: 104 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
name: key-package-lifecycle
description: Handles key package lifecycle in Marmot apps: creating and publishing packages, tracking kind-443 relay events, marking consumed packages, rotating stale/used packages, and deleting via remove or purge. Use when writing code that calls client.keyPackages, manages invite readiness, replenishes key package supply, or cleans up relay-published packages.
---

# Key Package Lifecycle

## API Overview

All operations live on `client.keyPackages` (`KeyPackageManager`):

| Method | Effect |
|---|---|
| `create(options)` | Generate, store locally, sign and publish kind-443 to relays |
| `track(event)` | Record an observed kind-443 event; returns `false` if not applicable |
| `markUsed(ref)` | Flag a package as consumed without deleting it |
| `rotate(ref, options?)` | Publish kind-5 deletion for old events, create+publish replacement, remove old local material |
| `remove(ref)` | Local store deletion only — no relay cleanup |
| `purge(refs)` | Publish kind-5 deletion for all known event IDs, remove local material (bulk-friendly) |
Comment on lines +10 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document list() and count() in the API overview.

The workflow example on Line 67 and Line 75 uses client.keyPackages.list() and client.keyPackages.count(), but this table omits both methods. That makes the documented surface inconsistent within the same guide.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SKILL.md` around lines 10 - 19, The table for KeyPackageManager
(client.keyPackages) is missing the list() and count() methods referenced later;
update the API overview table to include entries for list() — which returns an
array of stored key package metadata (or iterable) — and count() — which returns
the number of stored key packages, matching the behavior used in the workflow
example; add brief one-line descriptions for both methods alongside the existing
create/track/rotate/etc. entries so the table and examples are consistent.


## Core Rules

1. **Never transmit `privatePackage`** — private material stays local only.
2. **`create()` requires non-empty `relays`** — throws `MissingRelayError` otherwise.
3. **Track continuously** — feed all incoming kind-443 events to `track()` so publish records stay accurate across devices. This is required for `rotate()` and `purge()` to know which relay event IDs to delete.
4. **Prefer `rotate()` over `remove()` for published packages** — rotation cleans relays and replenishes supply atomically.
5. **`used` is rotation debt** — packages marked used should be rotated in the background, not immediately during the join flow.
6. **Relay deletion is best-effort** — kind-5 events are advisory; do not block UX on confirmation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

best-effort overstates the current behavior.

Line 28 suggests delete publication should not affect UX, but both rotate() and purge() currently await network.publish() and let publish failures reject the call (src/client/key-package-manager.ts:246-294 and src/client/key-package-manager.ts:327-365). Please either soften this wording or change the implementation to actually demote relay-delete failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SKILL.md` at line 28, The wording "best-effort" is inaccurate because
rotate() and purge() currently await network.publish() and let failures
propagate; update rotate() and purge() in key-package-manager to demote
relay-delete failures by catching errors from network.publish() (or not awaiting
it) and logging the failure instead of rethrowing, ensuring the functions still
complete successfully for UX flows while preserving telemetry of the publish
error.


## App Workflow

### 1. Bootstrap supply

Publish at least two key packages on onboarding (or when inventory is low) to reduce invite race failures:

```typescript
await client.keyPackages.create({ relays, isLastResort: true });
await client.keyPackages.create({ relays, isLastResort: true });
```

### 2. Ingest relay state

Subscribe to kind `443` for the user's pubkey and feed events to the manager:

```typescript
for (const event of kind443Events) {
await client.keyPackages.track(event); // false = not a valid key package, safe to ignore
}
```

### 3. After a group join (consumption)

`MarmotClient` calls `markUsed()` automatically when processing a Welcome. Apps should schedule rotation in the background — do not block the join flow:

```typescript
// After client.acceptInvite() resolves, rotate in background
void maintainKeyPackages(client, relays);
```

### 4. Periodic maintenance

```typescript
async function maintainKeyPackages(
client: MarmotClient,
relays: string[],
): Promise<void> {
const entries = await client.keyPackages.list();

for (const entry of entries) {
if (entry.used) {
await client.keyPackages.rotate(entry.keyPackageRef);
}
}

if ((await client.keyPackages.count()) < 2) {
await client.keyPackages.create({ relays, isLastResort: true });
}
}
Comment on lines +63 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Pass relay fallback into rotate() in this example.

Line 71 can throw KeyPackageRotatePreconditionError for entries that have no tracked published events. Since this helper already receives relays, the safer pattern is to pass them through during rotation.

Suggested doc fix
   for (const entry of entries) {
     if (entry.used) {
-      await client.keyPackages.rotate(entry.keyPackageRef);
+      await client.keyPackages.rotate(entry.keyPackageRef, { relays });
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function maintainKeyPackages(
client: MarmotClient,
relays: string[],
): Promise<void> {
const entries = await client.keyPackages.list();
for (const entry of entries) {
if (entry.used) {
await client.keyPackages.rotate(entry.keyPackageRef);
}
}
if ((await client.keyPackages.count()) < 2) {
await client.keyPackages.create({ relays, isLastResort: true });
}
}
async function maintainKeyPackages(
client: MarmotClient,
relays: string[],
): Promise<void> {
const entries = await client.keyPackages.list();
for (const entry of entries) {
if (entry.used) {
await client.keyPackages.rotate(entry.keyPackageRef, { relays });
}
}
if ((await client.keyPackages.count()) < 2) {
await client.keyPackages.create({ relays, isLastResort: true });
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SKILL.md` around lines 63 - 78, The maintainKeyPackages helper should pass
the relay fallback into the rotation call to avoid
KeyPackageRotatePreconditionError for entries without published events; update
the call to client.keyPackages.rotate(entry.keyPackageRef) inside
maintainKeyPackages to include the relays array (e.g.,
client.keyPackages.rotate(entry.keyPackageRef, { relays })) so the rotate
invocation receives the relay fallback.

```

### 5. User-initiated deletion

- **Delete everywhere** (relay + local): `await client.keyPackages.purge(refs)`
- **Local only** (package was never published, or relay cleanup already handled): `await client.keyPackages.remove(ref)`

For deletion UX that operates on raw Nostr events (e.g. a relay browser), track the events first so `purge()` has the event IDs:

```typescript
for (const event of eventsToDelete) {
await client.keyPackages.track(event);
}
const refs = eventsToDelete.map(getKeyPackageReference).filter(Boolean);
await client.keyPackages.purge(refs);
```

## Error Handling

| Error | Thrown by | Resolution |
|---|---|---|
| `MissingRelayError` | `create()` | Prompt user for relay configuration |
| `KeyPackageNotFoundError` | `rotate()` | Refresh local state; fall back to `create()` |
| `KeyPackageRotatePreconditionError` | `rotate()` | Pass `options.relays` explicitly |

If a package has no tracked `published` events, `rotate()`/`purge()` still removes local material but skips the kind-5 deletion step.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The rotate() part of this statement is incorrect.

If there are no tracked published events, purge() still removes local material, but rotate() does not always do that. rotate() throws KeyPackageRotatePreconditionError unless options.relays is provided or relays can be recovered from prior publish records (src/client/key-package-manager.ts:246-294). This caveat should be called out explicitly here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SKILL.md` at line 104, Update the SKILL.md sentence to clarify that purge()
will remove local material when there are no tracked published events, but
rotate() will not always do so: rotate() throws
KeyPackageRotatePreconditionError unless options.relays is supplied or relays
can be recovered from prior publish records, so explicitly mention this
precondition and the two ways to satisfy it (providing options.relays or
recovering relays from previous publish records).