@promptcellar/pc 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/codex-capture.js +9 -9
- package/hooks/gemini-capture.js +5 -9
- package/hooks/prompt-capture.js +5 -9
- package/package.json +2 -1
- package/src/commands/login.js +12 -116
- package/src/commands/save.js +5 -2
- package/src/lib/api.js +28 -47
- package/src/lib/context.js +14 -73
- package/src/lib/crypto.js +1 -39
- package/src/lib/device-transfer.js +1 -43
- package/README.md +0 -119
- package/docs/plans/2026-01-30-wa-auth-integration-design.md +0 -118
- package/docs/plans/2026-01-30-wa-auth-integration-plan.md +0 -776
- package/docs/plans/2026-02-03-multi-prompt-capture-design.md +0 -501
|
@@ -1,776 +0,0 @@
|
|
|
1
|
-
# wa-auth Integration Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
-
|
|
5
|
-
**Goal:** Migrate pc-cli authentication to wa-auth device flow, add client-side encryption for zero-knowledge prompt capture.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Three repos change: wa-auth gets `client_id` on device flow + JWT issuance, prompt-registry gets a JWT-to-API-key exchange endpoint, pc-cli rewrites login and adds encryption. Changes are independent per-repo and can be implemented in sequence: wa-auth first, prompt-registry second, pc-cli third.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Python/Flask (wa-auth, prompt-registry), Node.js/ESM (pc-cli), AES-256-GCM (encryption), JWT/HS256 (tokens), Node.js built-in `crypto` module.
|
|
10
|
-
|
|
11
|
-
**Design doc:** `docs/plans/2026-01-30-wa-auth-integration-design.md`
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## Task 1: wa-auth -- Add `client_id` to DeviceAuthRequest model
|
|
16
|
-
|
|
17
|
-
**Files:**
|
|
18
|
-
- Modify: `/home/ddnewell/weldedanvil/wa-auth/models.py:45-52`
|
|
19
|
-
|
|
20
|
-
**Step 1: Add `client_id` column to DeviceAuthRequest**
|
|
21
|
-
|
|
22
|
-
```python
|
|
23
|
-
class DeviceAuthRequest(db.Model):
|
|
24
|
-
__tablename__ = 'device_auth_requests'
|
|
25
|
-
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
26
|
-
device_code = db.Column(db.String(64), unique=True, nullable=False)
|
|
27
|
-
user_code = db.Column(db.String(16), unique=True, nullable=False)
|
|
28
|
-
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=True)
|
|
29
|
-
client_id = db.Column(db.String(64), nullable=True)
|
|
30
|
-
status = db.Column(db.String(16), default='pending')
|
|
31
|
-
expires_at = db.Column(db.DateTime, nullable=False)
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
**Step 2: Commit**
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
cd /home/ddnewell/weldedanvil/wa-auth
|
|
38
|
-
git add models.py
|
|
39
|
-
git commit -m "feat: add client_id column to DeviceAuthRequest model"
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## Task 2: wa-auth -- Accept `client_id` in `POST /device/code`
|
|
45
|
-
|
|
46
|
-
**Files:**
|
|
47
|
-
- Modify: `/home/ddnewell/weldedanvil/wa-auth/app.py:113-128`
|
|
48
|
-
|
|
49
|
-
**Step 1: Update device_code route to validate and store client_id**
|
|
50
|
-
|
|
51
|
-
```python
|
|
52
|
-
@app.route('/device/code', methods=['POST'])
|
|
53
|
-
def device_code():
|
|
54
|
-
payload = request.get_json(silent=True) or {}
|
|
55
|
-
client_id = payload.get('client_id')
|
|
56
|
-
|
|
57
|
-
# Validate client_id if provided
|
|
58
|
-
if client_id:
|
|
59
|
-
oidc_clients = app.config.get('OIDC_CLIENTS') or {}
|
|
60
|
-
if client_id not in oidc_clients:
|
|
61
|
-
return {'error': 'invalid_client'}, 400
|
|
62
|
-
|
|
63
|
-
code = secrets.token_urlsafe(32)
|
|
64
|
-
user_code = secrets.token_hex(4)
|
|
65
|
-
req = DeviceAuthRequest(
|
|
66
|
-
device_code=code,
|
|
67
|
-
user_code=user_code,
|
|
68
|
-
client_id=client_id,
|
|
69
|
-
expires_at=datetime.utcnow() + timedelta(minutes=10),
|
|
70
|
-
)
|
|
71
|
-
db.session.add(req)
|
|
72
|
-
db.session.commit()
|
|
73
|
-
return {
|
|
74
|
-
'device_code': code,
|
|
75
|
-
'user_code': user_code,
|
|
76
|
-
'verification_url': 'https://account.weldedanvil.com/device',
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
**Step 2: Commit**
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
git add app.py
|
|
84
|
-
git commit -m "feat: accept client_id in POST /device/code"
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## Task 3: wa-auth -- Return JWT from `POST /device/poll` on approval
|
|
90
|
-
|
|
91
|
-
**Files:**
|
|
92
|
-
- Modify: `/home/ddnewell/weldedanvil/wa-auth/app.py:168-192`
|
|
93
|
-
|
|
94
|
-
**Context:** wa-auth already has JWT generation in the `/token` endpoint. Check how it's done there and reuse the same pattern. The app config has `JWT_SECRET`, `JWT_ISSUER`, and `ACCESS_TOKEN_TTL_SECONDS`.
|
|
95
|
-
|
|
96
|
-
**Step 1: Find existing JWT generation code**
|
|
97
|
-
|
|
98
|
-
Read `/home/ddnewell/weldedanvil/wa-auth/app.py` to find the `/token` endpoint and its JWT creation logic. Reuse the same `jwt.encode()` call pattern.
|
|
99
|
-
|
|
100
|
-
**Step 2: Update device_poll to return JWT on approval**
|
|
101
|
-
|
|
102
|
-
When `status == 'approved'`, generate a JWT with:
|
|
103
|
-
- `sub`: `req.user_id`
|
|
104
|
-
- `aud`: `req.client_id` (from the stored device request)
|
|
105
|
-
- `iss`: `app.config['JWT_ISSUER']`
|
|
106
|
-
- `iat`: current timestamp
|
|
107
|
-
- `exp`: current timestamp + `app.config.get('ACCESS_TOKEN_TTL_SECONDS', 900)`
|
|
108
|
-
|
|
109
|
-
Return:
|
|
110
|
-
```json
|
|
111
|
-
{
|
|
112
|
-
"status": "approved",
|
|
113
|
-
"user_id": "...",
|
|
114
|
-
"access_token": "<jwt>"
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
If `req.client_id` is `None` (legacy requests without client_id), omit `aud` from JWT and don't return `access_token` -- just return the existing `user_id`-only response for backward compatibility.
|
|
119
|
-
|
|
120
|
-
**Step 3: Commit**
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
git add app.py
|
|
124
|
-
git commit -m "feat: return JWT from device poll on approval"
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
---
|
|
128
|
-
|
|
129
|
-
## Task 4: prompt-registry -- Add `POST /api/v1/auth/device-token` endpoint
|
|
130
|
-
|
|
131
|
-
**Files:**
|
|
132
|
-
- Modify: `/home/ddnewell/weldedanvil/prompt-registry/app.py`
|
|
133
|
-
|
|
134
|
-
**Context:** This endpoint exchanges a wa-auth JWT for a `pk_*` API key. It uses existing functions: `verify_account_token()` from `auth.py`, `create_or_get_user()` from `auth.py`, `generate_api_key()` and `hash_api_key()` from `app.py`.
|
|
135
|
-
|
|
136
|
-
**Step 1: Add the endpoint inside `register_routes(app)`**
|
|
137
|
-
|
|
138
|
-
Place it after the existing device_auth_verify route (around line 193). The endpoint:
|
|
139
|
-
|
|
140
|
-
1. Reads JWT from `Authorization: Bearer <jwt>` header
|
|
141
|
-
2. Validates with `verify_account_token(jwt_str)` -- checks signature, audience, issuer, expiry
|
|
142
|
-
3. Extracts `sub` (user ID) and `email` from JWT payload
|
|
143
|
-
4. Calls `create_or_get_user(account_sub=sub, email=email)` to resolve user
|
|
144
|
-
5. Reads `device_name` from request JSON body
|
|
145
|
-
6. Deletes any existing `UserApiKey` where `user_id == user.id` and `name == device_name`
|
|
146
|
-
7. Creates new `UserApiKey` with `generate_api_key()`, stores `hash_api_key(key)` and `key_prefix`
|
|
147
|
-
8. Checks if vault exists by fetching from wa-auth `GET /vault` using the JWT (or a server-to-server call)
|
|
148
|
-
9. Returns JSON response
|
|
149
|
-
|
|
150
|
-
```python
|
|
151
|
-
@app.route('/api/v1/auth/device-token', methods=['POST'])
|
|
152
|
-
def device_token_exchange():
|
|
153
|
-
"""Exchange a wa-auth JWT for a pk_* API key."""
|
|
154
|
-
auth_header = request.headers.get('Authorization', '')
|
|
155
|
-
if not auth_header.startswith('Bearer '):
|
|
156
|
-
return {'error': 'missing_token'}, 401
|
|
157
|
-
|
|
158
|
-
jwt_str = auth_header[7:]
|
|
159
|
-
payload, err = verify_account_token(jwt_str)
|
|
160
|
-
if err:
|
|
161
|
-
return {'error': err}, 401
|
|
162
|
-
|
|
163
|
-
account_sub = payload.get('sub')
|
|
164
|
-
email = payload.get('email')
|
|
165
|
-
if not account_sub:
|
|
166
|
-
return {'error': 'invalid_token'}, 401
|
|
167
|
-
|
|
168
|
-
user = create_or_get_user(account_sub, email)
|
|
169
|
-
|
|
170
|
-
data = request.get_json(silent=True) or {}
|
|
171
|
-
device_name = data.get('device_name', 'CLI')
|
|
172
|
-
|
|
173
|
-
# Auto-revoke previous key with same device name
|
|
174
|
-
existing = UserApiKey.query.filter_by(user_id=user.id, name=device_name).all()
|
|
175
|
-
for key in existing:
|
|
176
|
-
db.session.delete(key)
|
|
177
|
-
|
|
178
|
-
# Create new API key
|
|
179
|
-
raw_key = generate_api_key()
|
|
180
|
-
new_key = UserApiKey(
|
|
181
|
-
id=str(uuid.uuid4()),
|
|
182
|
-
user_id=user.id,
|
|
183
|
-
name=device_name,
|
|
184
|
-
key_hash=hash_api_key(raw_key),
|
|
185
|
-
key_prefix=raw_key[:12],
|
|
186
|
-
)
|
|
187
|
-
db.session.add(new_key)
|
|
188
|
-
db.session.commit()
|
|
189
|
-
|
|
190
|
-
# Check vault availability
|
|
191
|
-
vault_response = {'available': False}
|
|
192
|
-
try:
|
|
193
|
-
account_url = current_app.config['ACCOUNT_BASE_URL'].rstrip('/')
|
|
194
|
-
vault_resp = requests.get(
|
|
195
|
-
f"{account_url}/vault",
|
|
196
|
-
headers={'Authorization': f'Bearer {jwt_str}'},
|
|
197
|
-
timeout=5,
|
|
198
|
-
)
|
|
199
|
-
if vault_resp.status_code == 200:
|
|
200
|
-
vault_data = vault_resp.json()
|
|
201
|
-
vault_response = {
|
|
202
|
-
'available': True,
|
|
203
|
-
'encrypted_metadata': vault_data.get('encrypted_metadata'),
|
|
204
|
-
}
|
|
205
|
-
except Exception:
|
|
206
|
-
pass
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
'api_key': raw_key,
|
|
210
|
-
'email': user.email,
|
|
211
|
-
'vault': vault_response,
|
|
212
|
-
}, 201
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
**Important notes:**
|
|
216
|
-
- `uuid` import already exists at top of file
|
|
217
|
-
- `requests` import already exists (used by auth.py, check if app.py needs it too)
|
|
218
|
-
- `verify_account_token` and `create_or_get_user` need to be imported from `auth.py`
|
|
219
|
-
- The vault check calls wa-auth's `/vault` endpoint using the JWT as bearer token. wa-auth's `/vault` currently uses session auth -- this will need wa-auth to also accept JWT bearer auth on `/vault`. See Task 5.
|
|
220
|
-
|
|
221
|
-
**Step 2: Commit**
|
|
222
|
-
|
|
223
|
-
```bash
|
|
224
|
-
cd /home/ddnewell/weldedanvil/prompt-registry
|
|
225
|
-
git add app.py
|
|
226
|
-
git commit -m "feat: add device-token exchange endpoint"
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
---
|
|
230
|
-
|
|
231
|
-
## Task 5: wa-auth -- Accept JWT bearer auth on `GET /vault`
|
|
232
|
-
|
|
233
|
-
**Files:**
|
|
234
|
-
- Modify: `/home/ddnewell/weldedanvil/wa-auth/app.py:232-242`
|
|
235
|
-
|
|
236
|
-
**Context:** The vault endpoint currently only accepts session-based auth. prompt-registry needs to fetch vault metadata using the JWT it received. Add bearer token support.
|
|
237
|
-
|
|
238
|
-
**Step 1: Update get_vault to accept JWT bearer token**
|
|
239
|
-
|
|
240
|
-
```python
|
|
241
|
-
@app.route('/vault')
|
|
242
|
-
def get_vault():
|
|
243
|
-
user_id = session.get('user_id')
|
|
244
|
-
|
|
245
|
-
# Also accept JWT bearer token
|
|
246
|
-
if not user_id:
|
|
247
|
-
auth_header = request.headers.get('Authorization', '')
|
|
248
|
-
if auth_header.startswith('Bearer '):
|
|
249
|
-
try:
|
|
250
|
-
payload = jwt.decode(
|
|
251
|
-
auth_header[7:],
|
|
252
|
-
app.config['JWT_SECRET'],
|
|
253
|
-
algorithms=['HS256'],
|
|
254
|
-
options={'require': ['exp', 'sub']},
|
|
255
|
-
)
|
|
256
|
-
user_id = payload.get('sub')
|
|
257
|
-
except Exception:
|
|
258
|
-
pass
|
|
259
|
-
|
|
260
|
-
if not user_id:
|
|
261
|
-
return {'error': 'unauthorized'}, 401
|
|
262
|
-
|
|
263
|
-
vault = Vault.query.filter_by(user_id=user_id).first()
|
|
264
|
-
if not vault:
|
|
265
|
-
return {'error': 'not_found'}, 404
|
|
266
|
-
|
|
267
|
-
return {'vault_id': vault.id, 'encrypted_metadata': vault.encrypted_metadata}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
**Step 2: Commit**
|
|
271
|
-
|
|
272
|
-
```bash
|
|
273
|
-
cd /home/ddnewell/weldedanvil/wa-auth
|
|
274
|
-
git add app.py
|
|
275
|
-
git commit -m "feat: accept JWT bearer auth on GET /vault"
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
---
|
|
279
|
-
|
|
280
|
-
## Task 6: pc-cli -- Add new config fields
|
|
281
|
-
|
|
282
|
-
**Files:**
|
|
283
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/lib/config.js`
|
|
284
|
-
|
|
285
|
-
**Step 1: Add accountUrl, vaultMetadata, and vaultAvailable to schema and exports**
|
|
286
|
-
|
|
287
|
-
Add three new fields to the `Conf` schema:
|
|
288
|
-
|
|
289
|
-
```javascript
|
|
290
|
-
accountUrl: {
|
|
291
|
-
type: 'string',
|
|
292
|
-
default: 'https://account.weldedanvil.com'
|
|
293
|
-
},
|
|
294
|
-
vaultMetadata: {
|
|
295
|
-
type: 'string',
|
|
296
|
-
default: ''
|
|
297
|
-
},
|
|
298
|
-
vaultAvailable: {
|
|
299
|
-
type: 'boolean',
|
|
300
|
-
default: false
|
|
301
|
-
}
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
Add getter/setter exports:
|
|
305
|
-
|
|
306
|
-
```javascript
|
|
307
|
-
export function getAccountUrl() {
|
|
308
|
-
return config.get('accountUrl');
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function setAccountUrl(url) {
|
|
312
|
-
config.set('accountUrl', url);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function getVaultMetadata() {
|
|
316
|
-
return config.get('vaultMetadata');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export function setVaultMetadata(metadata) {
|
|
320
|
-
config.set('vaultMetadata', metadata);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export function isVaultAvailable() {
|
|
324
|
-
return config.get('vaultAvailable');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function setVaultAvailable(available) {
|
|
328
|
-
config.set('vaultAvailable', available);
|
|
329
|
-
}
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
**Step 2: Update clearApiKey to also clear vault state**
|
|
333
|
-
|
|
334
|
-
Rename to `clearCredentials` or update `clearApiKey`:
|
|
335
|
-
|
|
336
|
-
```javascript
|
|
337
|
-
export function clearApiKey() {
|
|
338
|
-
config.delete('apiKey');
|
|
339
|
-
config.delete('vaultMetadata');
|
|
340
|
-
config.set('vaultAvailable', false);
|
|
341
|
-
}
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
**Step 3: Commit**
|
|
345
|
-
|
|
346
|
-
```bash
|
|
347
|
-
cd /home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration
|
|
348
|
-
git add src/lib/config.js
|
|
349
|
-
git commit -m "feat: add vault and account config fields"
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
---
|
|
353
|
-
|
|
354
|
-
## Task 7: pc-cli -- Create crypto module
|
|
355
|
-
|
|
356
|
-
**Files:**
|
|
357
|
-
- Create: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/lib/crypto.js`
|
|
358
|
-
|
|
359
|
-
**Context:** Must match the encryption format used by the web client in `prompt-registry/static/js/vault.js`. The web client uses:
|
|
360
|
-
- AES-256-GCM via WebCrypto
|
|
361
|
-
- Random 12-byte IV per encryption
|
|
362
|
-
- Base64url encoding (no padding) for encrypted content and IV
|
|
363
|
-
- The master key is stored as `encrypted_metadata` JSON containing `wrapped_master_key`, `wrap_iv`, `prf_salt`, `hkdf_info`, `version`
|
|
364
|
-
|
|
365
|
-
**Important design note:** The vault master key is wrapped with a WebAuthn PRF-derived key. The CLI cannot perform passkey ceremonies. For the CLI to encrypt, it needs the raw master key. This requires a CLI key export mechanism in the web UI (out of scope for this plan -- see Task 10 for the interim approach).
|
|
366
|
-
|
|
367
|
-
For now, implement the encryption functions assuming the CLI has a raw AES-256 key (32 bytes, base64url encoded) stored in config as `vaultMetadata`. The key provisioning mechanism (how the CLI gets this key) will be addressed separately.
|
|
368
|
-
|
|
369
|
-
**Step 1: Create crypto.js**
|
|
370
|
-
|
|
371
|
-
```javascript
|
|
372
|
-
import { createCipheriv, randomBytes } from 'crypto';
|
|
373
|
-
|
|
374
|
-
function b64urlEncode(buffer) {
|
|
375
|
-
return Buffer.from(buffer)
|
|
376
|
-
.toString('base64')
|
|
377
|
-
.replace(/\+/g, '-')
|
|
378
|
-
.replace(/\//g, '_')
|
|
379
|
-
.replace(/=+$/, '');
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function b64urlDecode(str) {
|
|
383
|
-
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
384
|
-
while (base64.length % 4) base64 += '=';
|
|
385
|
-
return Buffer.from(base64, 'base64');
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export function encryptPrompt(plaintext, keyBase64url) {
|
|
389
|
-
const key = b64urlDecode(keyBase64url);
|
|
390
|
-
if (key.length !== 32) {
|
|
391
|
-
throw new Error('Invalid vault key length');
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const iv = randomBytes(12);
|
|
395
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
396
|
-
|
|
397
|
-
const encoded = Buffer.from(plaintext, 'utf8');
|
|
398
|
-
const encrypted = Buffer.concat([cipher.update(encoded), cipher.final()]);
|
|
399
|
-
const authTag = cipher.getAuthTag();
|
|
400
|
-
|
|
401
|
-
// AES-GCM ciphertext = encrypted + authTag (WebCrypto format)
|
|
402
|
-
const ciphertext = Buffer.concat([encrypted, authTag]);
|
|
403
|
-
|
|
404
|
-
return {
|
|
405
|
-
encrypted_content: b64urlEncode(ciphertext),
|
|
406
|
-
content_iv: b64urlEncode(iv),
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
export { b64urlEncode, b64urlDecode };
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
**Step 2: Commit**
|
|
414
|
-
|
|
415
|
-
```bash
|
|
416
|
-
git add src/lib/crypto.js
|
|
417
|
-
git commit -m "feat: add AES-256-GCM encryption module"
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
---
|
|
421
|
-
|
|
422
|
-
## Task 8: pc-cli -- Rewrite login command
|
|
423
|
-
|
|
424
|
-
**Files:**
|
|
425
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/commands/login.js`
|
|
426
|
-
|
|
427
|
-
**Step 1: Rewrite login.js**
|
|
428
|
-
|
|
429
|
-
The new flow:
|
|
430
|
-
1. Request device code from wa-auth (`POST /device/code` with `client_id`)
|
|
431
|
-
2. Show verification URL to user
|
|
432
|
-
3. Poll wa-auth (`POST /device/poll`) until approved
|
|
433
|
-
4. Exchange JWT for API key at prompt-registry (`POST /api/v1/auth/device-token`)
|
|
434
|
-
5. Store API key, vault metadata, and URLs
|
|
435
|
-
|
|
436
|
-
```javascript
|
|
437
|
-
import ora from 'ora';
|
|
438
|
-
import chalk from 'chalk';
|
|
439
|
-
import {
|
|
440
|
-
setApiKey, setApiUrl, setAccountUrl,
|
|
441
|
-
setVaultMetadata, setVaultAvailable,
|
|
442
|
-
isLoggedIn, getAccountUrl
|
|
443
|
-
} from '../lib/config.js';
|
|
444
|
-
|
|
445
|
-
const DEFAULT_API_URL = 'https://prompts.weldedanvil.com';
|
|
446
|
-
const DEFAULT_ACCOUNT_URL = 'https://account.weldedanvil.com';
|
|
447
|
-
const CLIENT_ID = 'prompts-prod';
|
|
448
|
-
|
|
449
|
-
async function requestDeviceCode(accountUrl) {
|
|
450
|
-
const response = await fetch(`${accountUrl}/device/code`, {
|
|
451
|
-
method: 'POST',
|
|
452
|
-
headers: { 'Content-Type': 'application/json' },
|
|
453
|
-
body: JSON.stringify({ client_id: CLIENT_ID }),
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
if (!response.ok) {
|
|
457
|
-
const data = await response.json().catch(() => ({}));
|
|
458
|
-
throw new Error(data.error || 'Failed to request device code');
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return response.json();
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
async function pollForApproval(accountUrl, deviceCode, expiresIn) {
|
|
465
|
-
const expiresAt = Date.now() + (expiresIn || 600) * 1000;
|
|
466
|
-
const interval = 5000;
|
|
467
|
-
|
|
468
|
-
while (Date.now() < expiresAt) {
|
|
469
|
-
await sleep(interval);
|
|
470
|
-
|
|
471
|
-
const response = await fetch(`${accountUrl}/device/poll`, {
|
|
472
|
-
method: 'POST',
|
|
473
|
-
headers: { 'Content-Type': 'application/json' },
|
|
474
|
-
body: JSON.stringify({ device_code: deviceCode }),
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
const data = await response.json();
|
|
478
|
-
|
|
479
|
-
if (data.status === 'approved' && data.access_token) {
|
|
480
|
-
return data;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (data.status === 'expired') {
|
|
484
|
-
throw new Error('Authorization request expired. Please try again.');
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// status === 'pending' -- keep polling
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
throw new Error('Authorization request timed out. Please try again.');
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function exchangeTokenForApiKey(apiUrl, jwt, deviceName) {
|
|
494
|
-
const response = await fetch(`${apiUrl}/api/v1/auth/device-token`, {
|
|
495
|
-
method: 'POST',
|
|
496
|
-
headers: {
|
|
497
|
-
'Authorization': `Bearer ${jwt}`,
|
|
498
|
-
'Content-Type': 'application/json',
|
|
499
|
-
},
|
|
500
|
-
body: JSON.stringify({ device_name: deviceName }),
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
if (!response.ok) {
|
|
504
|
-
const data = await response.json().catch(() => ({}));
|
|
505
|
-
throw new Error(data.error || 'Failed to exchange token');
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return response.json();
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function getDeviceName() {
|
|
512
|
-
const os = process.platform;
|
|
513
|
-
const hostname = process.env.HOSTNAME || process.env.COMPUTERNAME || 'CLI';
|
|
514
|
-
const osNames = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
|
515
|
-
return `${osNames[os] || os} - ${hostname}`;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function sleep(ms) {
|
|
519
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
export async function login(options) {
|
|
523
|
-
const apiUrl = options?.url || DEFAULT_API_URL;
|
|
524
|
-
const accountUrl = options?.accountUrl || DEFAULT_ACCOUNT_URL;
|
|
525
|
-
|
|
526
|
-
if (isLoggedIn()) {
|
|
527
|
-
console.log(chalk.yellow('Already logged in.'));
|
|
528
|
-
console.log('Run ' + chalk.cyan('pc logout') + ' first to switch accounts.\n');
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
console.log(chalk.bold('\nPromptCellar CLI Login\n'));
|
|
533
|
-
|
|
534
|
-
const spinner = ora('Connecting...').start();
|
|
535
|
-
|
|
536
|
-
try {
|
|
537
|
-
// Step 1: Request device code from wa-auth
|
|
538
|
-
const authData = await requestDeviceCode(accountUrl);
|
|
539
|
-
spinner.stop();
|
|
540
|
-
|
|
541
|
-
// Step 2: Show login URL
|
|
542
|
-
const verifyUrl = `${authData.verification_url}?code=${authData.user_code}`;
|
|
543
|
-
console.log('Open this URL in your browser to log in:\n');
|
|
544
|
-
console.log(chalk.cyan.bold(` ${verifyUrl}\n`));
|
|
545
|
-
console.log(chalk.dim(`Or go to ${authData.verification_url} and enter code: ${authData.user_code}\n`));
|
|
546
|
-
|
|
547
|
-
// Step 3: Poll for approval
|
|
548
|
-
spinner.start('Waiting for you to authorize in browser...');
|
|
549
|
-
const approval = await pollForApproval(accountUrl, authData.device_code);
|
|
550
|
-
spinner.text = 'Setting up credentials...';
|
|
551
|
-
|
|
552
|
-
// Step 4: Exchange JWT for API key
|
|
553
|
-
const deviceName = getDeviceName();
|
|
554
|
-
const result = await exchangeTokenForApiKey(apiUrl, approval.access_token, deviceName);
|
|
555
|
-
|
|
556
|
-
// Step 5: Store credentials
|
|
557
|
-
setApiKey(result.api_key);
|
|
558
|
-
setApiUrl(apiUrl);
|
|
559
|
-
setAccountUrl(accountUrl);
|
|
560
|
-
|
|
561
|
-
if (result.vault && result.vault.available) {
|
|
562
|
-
setVaultMetadata(result.vault.encrypted_metadata);
|
|
563
|
-
setVaultAvailable(true);
|
|
564
|
-
} else {
|
|
565
|
-
setVaultMetadata('');
|
|
566
|
-
setVaultAvailable(false);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
spinner.succeed(chalk.green('Logged in successfully!'));
|
|
570
|
-
console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
|
|
571
|
-
|
|
572
|
-
if (!result.vault || !result.vault.available) {
|
|
573
|
-
console.log(chalk.yellow('\nVault not set up.') + ' Prompt capture requires a vault.');
|
|
574
|
-
console.log('Set up your vault at: ' + chalk.cyan(`${apiUrl}/dashboard/settings`));
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
console.log('\nRun ' + chalk.cyan('pc setup') + ' to configure auto-capture for your CLI tools.\n');
|
|
578
|
-
|
|
579
|
-
} catch (error) {
|
|
580
|
-
spinner.fail(chalk.red('Login failed: ' + error.message));
|
|
581
|
-
console.log('\nPlease try again or visit ' + chalk.cyan(apiUrl) + ' to sign up.\n');
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export default login;
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
**Step 2: Commit**
|
|
589
|
-
|
|
590
|
-
```bash
|
|
591
|
-
git add src/commands/login.js
|
|
592
|
-
git commit -m "feat: rewrite login to use wa-auth device flow"
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
---
|
|
596
|
-
|
|
597
|
-
## Task 9: pc-cli -- Update hooks and save to use encryption
|
|
598
|
-
|
|
599
|
-
**Files:**
|
|
600
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/hooks/prompt-capture.js`
|
|
601
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/hooks/codex-capture.js`
|
|
602
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/hooks/gemini-capture.js`
|
|
603
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/commands/save.js`
|
|
604
|
-
|
|
605
|
-
**Step 1: Update prompt-capture.js**
|
|
606
|
-
|
|
607
|
-
Add vault check and encryption. Key changes:
|
|
608
|
-
- Import `isVaultAvailable`, `getVaultMetadata` from config
|
|
609
|
-
- Import `encryptPrompt` from crypto
|
|
610
|
-
- After `isLoggedIn()` check, add `isVaultAvailable()` check -- exit silently if not available
|
|
611
|
-
- Before calling `capturePrompt()`, encrypt the content
|
|
612
|
-
- Send `{ encrypted_content, content_iv }` instead of `{ content }`
|
|
613
|
-
|
|
614
|
-
```javascript
|
|
615
|
-
import { isLoggedIn, isVaultAvailable, getVaultMetadata } from '../src/lib/config.js';
|
|
616
|
-
import { encryptPrompt } from '../src/lib/crypto.js';
|
|
617
|
-
|
|
618
|
-
// ... (existing code) ...
|
|
619
|
-
|
|
620
|
-
// After isLoggedIn check:
|
|
621
|
-
if (!isVaultAvailable()) {
|
|
622
|
-
process.exit(0);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Before capturePrompt call:
|
|
626
|
-
const vaultKey = getVaultMetadata();
|
|
627
|
-
const { encrypted_content, content_iv } = encryptPrompt(initialPrompt.content, vaultKey);
|
|
628
|
-
|
|
629
|
-
await capturePrompt({
|
|
630
|
-
encrypted_content,
|
|
631
|
-
content_iv,
|
|
632
|
-
...context,
|
|
633
|
-
captured_at: initialPrompt.timestamp
|
|
634
|
-
});
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
Apply the same pattern to `codex-capture.js` and `gemini-capture.js`.
|
|
638
|
-
|
|
639
|
-
**Step 2: Update save.js**
|
|
640
|
-
|
|
641
|
-
Add vault check before save. If vault not available, show actionable error:
|
|
642
|
-
|
|
643
|
-
```javascript
|
|
644
|
-
import { isLoggedIn, isVaultAvailable, getVaultMetadata, getApiUrl } from '../lib/config.js';
|
|
645
|
-
import { encryptPrompt } from '../lib/crypto.js';
|
|
646
|
-
|
|
647
|
-
// In save():
|
|
648
|
-
if (!isVaultAvailable()) {
|
|
649
|
-
console.log(chalk.red('Vault not set up.') + ' Prompt capture requires a vault.');
|
|
650
|
-
console.log('Set up your vault at: ' + chalk.cyan(`${getApiUrl()}/dashboard/settings`));
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Before capturePrompt:
|
|
655
|
-
const vaultKey = getVaultMetadata();
|
|
656
|
-
const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
|
|
657
|
-
|
|
658
|
-
const result = await capturePrompt({
|
|
659
|
-
encrypted_content,
|
|
660
|
-
content_iv,
|
|
661
|
-
...context
|
|
662
|
-
});
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
**Step 3: Commit**
|
|
666
|
-
|
|
667
|
-
```bash
|
|
668
|
-
git add hooks/prompt-capture.js hooks/codex-capture.js hooks/gemini-capture.js src/commands/save.js
|
|
669
|
-
git commit -m "feat: encrypt prompts before capture"
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
---
|
|
673
|
-
|
|
674
|
-
## Task 10: pc-cli -- Update status command to show vault status
|
|
675
|
-
|
|
676
|
-
**Files:**
|
|
677
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/commands/status.js`
|
|
678
|
-
|
|
679
|
-
**Step 1: Add vault status display**
|
|
680
|
-
|
|
681
|
-
```javascript
|
|
682
|
-
import { isLoggedIn, getApiUrl, getCaptureLevel, getSessionId, isVaultAvailable } from '../lib/config.js';
|
|
683
|
-
|
|
684
|
-
// After capture level display:
|
|
685
|
-
if (isVaultAvailable()) {
|
|
686
|
-
console.log(` Vault: ${chalk.green('configured')}`);
|
|
687
|
-
} else {
|
|
688
|
-
console.log(` Vault: ${chalk.yellow('not configured')} - capture disabled`);
|
|
689
|
-
}
|
|
690
|
-
```
|
|
691
|
-
|
|
692
|
-
**Step 2: Commit**
|
|
693
|
-
|
|
694
|
-
```bash
|
|
695
|
-
git add src/commands/status.js
|
|
696
|
-
git commit -m "feat: show vault status in pc status"
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
---
|
|
700
|
-
|
|
701
|
-
## Task 11: pc-cli -- Update logout to clear vault state
|
|
702
|
-
|
|
703
|
-
**Files:**
|
|
704
|
-
- Modify: `/home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration/src/commands/logout.js`
|
|
705
|
-
|
|
706
|
-
**Step 1: Read logout.js and verify it calls clearApiKey**
|
|
707
|
-
|
|
708
|
-
The `clearApiKey` function was updated in Task 6 to also clear vault state. Verify logout.js calls it. If it uses a different clearing mechanism, update accordingly.
|
|
709
|
-
|
|
710
|
-
**Step 2: Commit if changes needed**
|
|
711
|
-
|
|
712
|
-
```bash
|
|
713
|
-
git add src/commands/logout.js
|
|
714
|
-
git commit -m "fix: ensure logout clears vault state"
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
---
|
|
718
|
-
|
|
719
|
-
## Task 12: Manual integration test
|
|
720
|
-
|
|
721
|
-
**No code changes -- verification only.**
|
|
722
|
-
|
|
723
|
-
**Step 1: Verify wa-auth changes**
|
|
724
|
-
|
|
725
|
-
```bash
|
|
726
|
-
cd /home/ddnewell/weldedanvil/wa-auth
|
|
727
|
-
# Run existing tests if any
|
|
728
|
-
python -m pytest tests/ -v 2>/dev/null || echo "Check tests manually"
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
**Step 2: Verify prompt-registry changes**
|
|
732
|
-
|
|
733
|
-
```bash
|
|
734
|
-
cd /home/ddnewell/weldedanvil/prompt-registry
|
|
735
|
-
python -m pytest tests/ -v 2>/dev/null || echo "Check tests manually"
|
|
736
|
-
```
|
|
737
|
-
|
|
738
|
-
**Step 3: Verify pc-cli syntax**
|
|
739
|
-
|
|
740
|
-
```bash
|
|
741
|
-
cd /home/ddnewell/weldedanvil/pc-cli/.worktrees/wa-auth-integration
|
|
742
|
-
node -e "import('./src/lib/crypto.js').then(m => { const r = m.encryptPrompt('test', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); console.log('OK:', r); })"
|
|
743
|
-
node -e "import('./src/lib/config.js').then(m => { console.log('Config OK'); })"
|
|
744
|
-
node -e "import('./src/commands/login.js').then(m => { console.log('Login OK'); })"
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
**Step 4: Verify end-to-end flow description**
|
|
748
|
-
|
|
749
|
-
Confirm the flow works conceptually:
|
|
750
|
-
1. `pc login` → calls wa-auth `/device/code` with `client_id=prompts-prod`
|
|
751
|
-
2. User approves in browser
|
|
752
|
-
3. CLI polls → gets JWT with `aud=prompts-prod`
|
|
753
|
-
4. CLI calls prompt-registry `/api/v1/auth/device-token` with JWT
|
|
754
|
-
5. Gets back `pk_*` key + vault metadata
|
|
755
|
-
6. `pc save -m "test"` → encrypts with vault key → sends encrypted blob
|
|
756
|
-
7. `pc status` → shows vault status
|
|
757
|
-
|
|
758
|
-
---
|
|
759
|
-
|
|
760
|
-
## Notes
|
|
761
|
-
|
|
762
|
-
### Vault key provisioning (deferred)
|
|
763
|
-
|
|
764
|
-
The vault master key is wrapped with a WebAuthn PRF-derived key. The CLI cannot perform passkey ceremonies. A separate mechanism is needed to export the raw master key to the CLI. Options:
|
|
765
|
-
|
|
766
|
-
1. Web UI "Enable CLI Access" button that re-wraps master key with a CLI-specific passphrase
|
|
767
|
-
2. QR code in web UI containing the raw key for one-time transfer
|
|
768
|
-
3. Temporary API endpoint that returns the raw key during an authenticated browser session
|
|
769
|
-
|
|
770
|
-
This is intentionally deferred. For initial development, the `vaultMetadata` config field stores the raw key directly (base64url-encoded 32 bytes). The provisioning UX will be designed separately.
|
|
771
|
-
|
|
772
|
-
### Cross-repo deployment order
|
|
773
|
-
|
|
774
|
-
1. Deploy wa-auth (Tasks 1-3, 5) -- backward compatible, existing device flow still works
|
|
775
|
-
2. Deploy prompt-registry (Task 4) -- new endpoint, no existing behavior changes
|
|
776
|
-
3. Release pc-cli (Tasks 6-11) -- requires both backends deployed first
|