@indigoai-us/hq-cli 5.43.0 → 5.44.0
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/CHANGELOG.md +23 -0
- package/dist/commands/publish.d.ts +17 -2
- package/dist/commands/publish.js +50 -11
- package/dist/utils/cognito-session.d.ts +14 -0
- package/dist/utils/cognito-session.js +44 -2
- package/package.json +1 -1
- package/src/commands/publish.test.ts +107 -0
- package/src/commands/publish.ts +70 -9
- package/src/utils/cognito-session.ts +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.44.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq publish --as <handle>` (alias `--on-behalf <handle>`) — publish on
|
|
10
|
+
behalf of a seeded creator.** Lets HQ staff publish a pack attributed to a
|
|
11
|
+
seeded, unclaimed creator (e.g. an upstream OSS author ported into
|
|
12
|
+
`hq-packages`) instead of to themselves. Pairs with the staff-gated
|
|
13
|
+
`publishAsHandle` capability in hq-pro (#508): the server requires staff
|
|
14
|
+
(`@getindigo.ai`), restricts attribution to unclaimed creators, and is
|
|
15
|
+
gated by an off-by-default kill-switch. Without `--as`, publish behavior is
|
|
16
|
+
unchanged (normal verified-creator self-publish). On the `--as` path the CLI
|
|
17
|
+
leaves the manifest author intact (the server is authoritative for
|
|
18
|
+
attribution) rather than stamping the caller.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **On-behalf publish authenticates with the ID token, not the access token.**
|
|
23
|
+
The server's staff gate reads the `email` claim, which Cognito puts on the
|
|
24
|
+
id_token only — the access token returns `403 NO_EMAIL_CLAIM`. `--as` now
|
|
25
|
+
sends the id_token (new `ensureCognitoIdToken`); normal self-publish keeps
|
|
26
|
+
using the access token.
|
|
27
|
+
|
|
5
28
|
## [5.41.0]
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -144,14 +144,27 @@ export interface ListingRequest {
|
|
|
144
144
|
summary?: string;
|
|
145
145
|
contributes?: string;
|
|
146
146
|
creatorHandle?: string;
|
|
147
|
+
/**
|
|
148
|
+
* OPTIONAL staff-gated publish-on-behalf target handle (`hq publish --as
|
|
149
|
+
* <handle>`). When set, the server attributes the listing to the seeded,
|
|
150
|
+
* UNCLAIMED creator with this handle instead of the caller — but only if the
|
|
151
|
+
* caller is staff (`@getindigo.ai`) and the server has the capability enabled.
|
|
152
|
+
*/
|
|
153
|
+
publishAsHandle?: string;
|
|
147
154
|
/** Base64-encoded gzip pack tarball (pack.tar.gz bytes). */
|
|
148
155
|
tarball: string;
|
|
149
156
|
}
|
|
150
157
|
export interface PublishDeps {
|
|
151
158
|
/** Returns the cached idToken, or null/expired if not logged in. */
|
|
152
159
|
loadIdToken: () => string | null;
|
|
153
|
-
/** Returns a non-interactive access token. */
|
|
160
|
+
/** Returns a non-interactive access token (normal self-publish). */
|
|
154
161
|
getAccessToken: () => Promise<string>;
|
|
162
|
+
/**
|
|
163
|
+
* Returns a non-interactive ID token. Required for an on-behalf publish
|
|
164
|
+
* (`--as <handle>`): the server's staff gate reads the `email` claim, which is
|
|
165
|
+
* on the id_token only — the access token returns 403 NO_EMAIL_CLAIM.
|
|
166
|
+
*/
|
|
167
|
+
getIdToken: () => Promise<string>;
|
|
155
168
|
/**
|
|
156
169
|
* POSTs the listing as a JSON body to `/v1/listings` and returns the parsed
|
|
157
170
|
* { status, body }. `tarball` is the BASE64-encoded gzip pack bytes — the
|
|
@@ -181,6 +194,8 @@ export interface PublishResult {
|
|
|
181
194
|
* success notice; throws (uploading nothing) on logged-out or validation
|
|
182
195
|
* failure.
|
|
183
196
|
*/
|
|
184
|
-
export declare function runPublish(targetPath: string, deps: PublishDeps
|
|
197
|
+
export declare function runPublish(targetPath: string, deps: PublishDeps, opts?: {
|
|
198
|
+
publishAsHandle?: string;
|
|
199
|
+
}): Promise<PublishResult>;
|
|
185
200
|
export declare function registerPublishCommand(program: Command): void;
|
|
186
201
|
//# sourceMappingURL=publish.d.ts.map
|
package/dist/commands/publish.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* - manifest.ts (modules.yaml read/write for provenance)
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
31
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e2f6607e-188c-57f9-beba-a81488d15225")}catch(e){}}();
|
|
32
32
|
import * as fs from 'fs';
|
|
33
33
|
import * as os from 'os';
|
|
34
34
|
import * as path from 'path';
|
|
@@ -36,7 +36,7 @@ import * as yaml from 'js-yaml';
|
|
|
36
36
|
import { execFileSync } from 'child_process';
|
|
37
37
|
import chalk from 'chalk';
|
|
38
38
|
import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
39
|
-
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
39
|
+
import { ensureCognitoToken, ensureCognitoIdToken } from '../utils/cognito-session.js';
|
|
40
40
|
import { vaultApiFetch } from '../utils/vault-api.js';
|
|
41
41
|
import { validateManifest } from './pack-install.js';
|
|
42
42
|
import { findHqRoot, readManifest, writeManifest } from '../utils/manifest.js';
|
|
@@ -237,6 +237,14 @@ export function buildListingNotice(status, body, packName, packVersion) {
|
|
|
237
237
|
throw new Error(`${packName}@${packVersion} is already published (duplicate version). ` +
|
|
238
238
|
`Bump the version in package.yaml and re-run \`hq publish\`.`);
|
|
239
239
|
}
|
|
240
|
+
if (status === 400) {
|
|
241
|
+
const code = typeof body.code === 'string' ? body.code : '';
|
|
242
|
+
if (code === 'ON_BEHALF_DISABLED') {
|
|
243
|
+
throw new Error('Publishing on behalf of another creator is not enabled on this server. ' +
|
|
244
|
+
'Ask an HQ admin to turn on the on-behalf capability, or publish without `--as`.');
|
|
245
|
+
}
|
|
246
|
+
// Any other 400 falls through to the generic non-2xx handler below.
|
|
247
|
+
}
|
|
240
248
|
if (status === 401 || status === 403) {
|
|
241
249
|
const code = typeof body.code === 'string' ? body.code : '';
|
|
242
250
|
if (code === 'NOT_VERIFIED_CREATOR') {
|
|
@@ -244,6 +252,13 @@ export function buildListingNotice(status, body, packName, packVersion) {
|
|
|
244
252
|
'Run `hq creators apply --reason "..."` to request verified-creator access; ' +
|
|
245
253
|
'an Indigo admin will review it.');
|
|
246
254
|
}
|
|
255
|
+
if (code === 'FORBIDDEN' || code === 'NO_EMAIL_CLAIM') {
|
|
256
|
+
// The staff gate rejected an on-behalf (`--as`) publish — only the normal
|
|
257
|
+
// self-publish path returns NOT_VERIFIED_CREATOR, so these codes here mean
|
|
258
|
+
// a non-staff caller tried to publish as someone else.
|
|
259
|
+
throw new Error('Only HQ staff can publish on behalf of another creator (`--as`). ' +
|
|
260
|
+
'Re-run without `--as` to publish as yourself.');
|
|
261
|
+
}
|
|
247
262
|
throw new Error('Not authorized to publish — run `hq login` and ensure your creator account is verified.');
|
|
248
263
|
}
|
|
249
264
|
if (status < 200 || status >= 300) {
|
|
@@ -263,13 +278,16 @@ export function buildListingNotice(status, body, packName, packVersion) {
|
|
|
263
278
|
* success notice; throws (uploading nothing) on logged-out or validation
|
|
264
279
|
* failure.
|
|
265
280
|
*/
|
|
266
|
-
export async function runPublish(targetPath, deps) {
|
|
281
|
+
export async function runPublish(targetPath, deps, opts = {}) {
|
|
267
282
|
// 1. Logged-in gate — refuse BEFORE any packing/upload.
|
|
268
283
|
const idToken = deps.loadIdToken();
|
|
269
284
|
if (!idToken) {
|
|
270
285
|
throw new Error('Not logged in — run `hq login` before publishing. Nothing was uploaded.');
|
|
271
286
|
}
|
|
272
287
|
const author = resolveAuthor(peekIdToken(idToken));
|
|
288
|
+
const publishAsHandle = typeof opts.publishAsHandle === 'string' && opts.publishAsHandle.length > 0
|
|
289
|
+
? opts.publishAsHandle
|
|
290
|
+
: undefined;
|
|
273
291
|
// Resolve a payload dir. We stamp/validate against a temp copy so we never
|
|
274
292
|
// mutate the user's working tree's package.yaml.
|
|
275
293
|
const absTarget = path.resolve(process.cwd(), targetPath);
|
|
@@ -285,7 +303,14 @@ export async function runPublish(targetPath, deps) {
|
|
|
285
303
|
if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
|
|
286
304
|
throw new Error(`package.yaml missing from ${absTarget} — nothing was uploaded.`);
|
|
287
305
|
}
|
|
288
|
-
|
|
306
|
+
// On the on-behalf path (`--as <handle>`) the SERVER is authoritative for
|
|
307
|
+
// attribution (it resolves the target creator by handle and ignores the
|
|
308
|
+
// manifest/stamped author), so we must NOT overwrite the manifest's author
|
|
309
|
+
// with the CALLER's identity — the manifest already names the target
|
|
310
|
+
// creator. Stamp the caller only on the normal self-publish path.
|
|
311
|
+
if (!publishAsHandle) {
|
|
312
|
+
stampAuthorIntoPackage(payloadDir, author);
|
|
313
|
+
}
|
|
289
314
|
// 3. Validate with the canonical 10-rule validator. Throws on any failure;
|
|
290
315
|
// we have uploaded nothing at this point.
|
|
291
316
|
const pkg = validateManifest(payloadDir, deps.hqVersion);
|
|
@@ -293,7 +318,13 @@ export async function runPublish(targetPath, deps) {
|
|
|
293
318
|
// JSON.parses the body, so we send the gzip tarball base64-encoded
|
|
294
319
|
// inside a JSON listing request (NOT a raw octet-stream body).
|
|
295
320
|
const tarballBytes = tarballPayload(payloadDir);
|
|
296
|
-
|
|
321
|
+
// On-behalf publishes are staff-gated server-side and the staff gate reads
|
|
322
|
+
// the id_token `email` claim — send the ID token, not the access token
|
|
323
|
+
// (which has no email and would 403 NO_EMAIL_CLAIM). Normal self-publish
|
|
324
|
+
// continues to use the access token.
|
|
325
|
+
const token = publishAsHandle
|
|
326
|
+
? await deps.getIdToken()
|
|
327
|
+
: await deps.getAccessToken();
|
|
297
328
|
const listing = {
|
|
298
329
|
type: inferListingType(pkg),
|
|
299
330
|
name: pkg.name,
|
|
@@ -301,9 +332,12 @@ export async function runPublish(targetPath, deps) {
|
|
|
301
332
|
version: pkg.version,
|
|
302
333
|
summary: pkg.description,
|
|
303
334
|
contributes: summarizeContributes(pkg.contributes) || undefined,
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
335
|
+
// On-behalf: send the target handle so the body is self-describing (the
|
|
336
|
+
// server is authoritative for attribution either way). Normal: our
|
|
337
|
+
// resolved handle is a backwards-compat fallback under the server's
|
|
338
|
+
// claimed-handle lookup.
|
|
339
|
+
creatorHandle: publishAsHandle ?? author.handle,
|
|
340
|
+
...(publishAsHandle ? { publishAsHandle } : {}),
|
|
307
341
|
tarball: Buffer.from(tarballBytes).toString('base64'),
|
|
308
342
|
};
|
|
309
343
|
const { status, body } = await deps.upload({ token, listing });
|
|
@@ -326,8 +360,12 @@ export function registerPublishCommand(program) {
|
|
|
326
360
|
program
|
|
327
361
|
.command('publish <path>')
|
|
328
362
|
.description('Package a skill/worker pack and submit it to the HQ marketplace (POST /v1/listings).')
|
|
329
|
-
.
|
|
363
|
+
.option('--as <handle>', 'Publish on behalf of a seeded, unclaimed creator by handle (HQ staff only). ' +
|
|
364
|
+
'The listing is attributed to that creator instead of you.')
|
|
365
|
+
.option('--on-behalf <handle>', 'Alias for --as.')
|
|
366
|
+
.action(async (targetPath, options) => {
|
|
330
367
|
try {
|
|
368
|
+
const publishAsHandle = options.as ?? options.onBehalf;
|
|
331
369
|
const hqRoot = findHqRoot();
|
|
332
370
|
const result = await runPublish(targetPath, {
|
|
333
371
|
loadIdToken: () => {
|
|
@@ -337,6 +375,7 @@ export function registerPublishCommand(program) {
|
|
|
337
375
|
return cached.idToken;
|
|
338
376
|
},
|
|
339
377
|
getAccessToken: () => ensureCognitoToken({ interactive: false }),
|
|
378
|
+
getIdToken: () => ensureCognitoIdToken({ interactive: false }),
|
|
340
379
|
upload: async ({ token, listing }) => {
|
|
341
380
|
// JSON body (application/json) — the deployed handler JSON.parses
|
|
342
381
|
// the body and base64-decodes `listing.tarball`. vaultApiFetch
|
|
@@ -352,7 +391,7 @@ export function registerPublishCommand(program) {
|
|
|
352
391
|
},
|
|
353
392
|
hqRoot,
|
|
354
393
|
hqVersion: readHqVersion(hqRoot),
|
|
355
|
-
});
|
|
394
|
+
}, { publishAsHandle });
|
|
356
395
|
console.log(chalk.green(result.notice));
|
|
357
396
|
// Only print the locally-resolved author as a fallback when the server
|
|
358
397
|
// did NOT return its own resolved handle (already in the notice).
|
|
@@ -367,4 +406,4 @@ export function registerPublishCommand(program) {
|
|
|
367
406
|
});
|
|
368
407
|
}
|
|
369
408
|
//# sourceMappingURL=publish.js.map
|
|
370
|
-
//# debugId=
|
|
409
|
+
//# debugId=e2f6607e-188c-57f9-beba-a81488d15225
|
|
@@ -83,6 +83,20 @@ export declare const DEFAULT_HQ_ROOT: string;
|
|
|
83
83
|
export declare function ensureCognitoToken(options?: {
|
|
84
84
|
interactive?: boolean;
|
|
85
85
|
}): Promise<string>;
|
|
86
|
+
/**
|
|
87
|
+
* Return a non-expired Cognito **ID** token (mirrors `ensureCognitoToken`'s
|
|
88
|
+
* refresh/login flow but yields the id_token, not the access token).
|
|
89
|
+
*
|
|
90
|
+
* Staff-gated vault-API routes (`assertStaffCaller`) read the `email` claim,
|
|
91
|
+
* which Cognito puts on the ID token ONLY — the access token has no `email`, so
|
|
92
|
+
* sending it returns `403 NO_EMAIL_CLAIM`. Callers performing a staff action
|
|
93
|
+
* (e.g. `hq publish --as <handle>`) MUST send the ID token. For machine
|
|
94
|
+
* identities the vault API already authenticates with the ID token, so this is
|
|
95
|
+
* identical to `ensureCognitoToken` there.
|
|
96
|
+
*/
|
|
97
|
+
export declare function ensureCognitoIdToken(options?: {
|
|
98
|
+
interactive?: boolean;
|
|
99
|
+
}): Promise<string>;
|
|
86
100
|
/**
|
|
87
101
|
* The CLI's own ClientInfo. Resolved once at module load so every VaultClient
|
|
88
102
|
* built in this process stamps the same name/version onto its requests.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* HQ_VAULT_API_URL — vault-service API Gateway URL
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
22
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="21e260f0-50fd-54c7-944a-c15630c42f3e")}catch(e){}}();
|
|
23
23
|
import * as fs from "fs";
|
|
24
24
|
import * as os from "os";
|
|
25
25
|
import * as path from "path";
|
|
@@ -269,6 +269,48 @@ export async function ensureCognitoToken(options = {}) {
|
|
|
269
269
|
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
270
270
|
return tokens.accessToken;
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Return a non-expired Cognito **ID** token (mirrors `ensureCognitoToken`'s
|
|
274
|
+
* refresh/login flow but yields the id_token, not the access token).
|
|
275
|
+
*
|
|
276
|
+
* Staff-gated vault-API routes (`assertStaffCaller`) read the `email` claim,
|
|
277
|
+
* which Cognito puts on the ID token ONLY — the access token has no `email`, so
|
|
278
|
+
* sending it returns `403 NO_EMAIL_CLAIM`. Callers performing a staff action
|
|
279
|
+
* (e.g. `hq publish --as <handle>`) MUST send the ID token. For machine
|
|
280
|
+
* identities the vault API already authenticates with the ID token, so this is
|
|
281
|
+
* identical to `ensureCognitoToken` there.
|
|
282
|
+
*/
|
|
283
|
+
export async function ensureCognitoIdToken(options = {}) {
|
|
284
|
+
const interactive = options.interactive ?? true;
|
|
285
|
+
if (isMachineIdentity()) {
|
|
286
|
+
const machine = await getValidMachineTokens(DEFAULT_COGNITO);
|
|
287
|
+
return machine.idToken;
|
|
288
|
+
}
|
|
289
|
+
const cached = loadCachedTokens();
|
|
290
|
+
if (cached && !isExpiring(cached, 120)) {
|
|
291
|
+
return cached.idToken;
|
|
292
|
+
}
|
|
293
|
+
if (cached) {
|
|
294
|
+
try {
|
|
295
|
+
if (interactive) {
|
|
296
|
+
console.error(chalk.dim(" Refreshing expiring HQ session..."));
|
|
297
|
+
}
|
|
298
|
+
const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
|
|
299
|
+
return refreshed.idToken;
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
if (interactive) {
|
|
303
|
+
console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!interactive) {
|
|
308
|
+
throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
|
|
309
|
+
}
|
|
310
|
+
console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
|
|
311
|
+
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
312
|
+
return tokens.idToken;
|
|
313
|
+
}
|
|
272
314
|
/**
|
|
273
315
|
* The CLI's own ClientInfo. Resolved once at module load so every VaultClient
|
|
274
316
|
* built in this process stamps the same name/version onto its requests.
|
|
@@ -328,4 +370,4 @@ export async function refreshCachedSession() {
|
|
|
328
370
|
}
|
|
329
371
|
}
|
|
330
372
|
//# sourceMappingURL=cognito-session.js.map
|
|
331
|
-
//# debugId=
|
|
373
|
+
//# debugId=21e260f0-50fd-54c7-944a-c15630c42f3e
|
package/package.json
CHANGED
|
@@ -171,6 +171,16 @@ describe('buildListingNotice', () => {
|
|
|
171
171
|
buildListingNotice(403, { code: 'NOT_VERIFIED_CREATOR' }, 'hq-pack-demo', '1.0.0'),
|
|
172
172
|
).toThrow(/hq creators apply/i);
|
|
173
173
|
});
|
|
174
|
+
it('maps a 400 ON_BEHALF_DISABLED to a clear "not enabled" message', () => {
|
|
175
|
+
expect(() =>
|
|
176
|
+
buildListingNotice(400, { code: 'ON_BEHALF_DISABLED' }, 'hq-pack-demo', '1.0.0'),
|
|
177
|
+
).toThrow(/on behalf|not enabled/i);
|
|
178
|
+
});
|
|
179
|
+
it('maps a 403 staff-gate (FORBIDDEN) on an --as publish to a "staff only" message', () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
buildListingNotice(403, { code: 'FORBIDDEN' }, 'hq-pack-demo', '1.0.0'),
|
|
182
|
+
).toThrow(/HQ staff|on behalf/i);
|
|
183
|
+
});
|
|
174
184
|
it('surfaces a generic server error body', () => {
|
|
175
185
|
expect(() => buildListingNotice(500, { error: 'boom' }, 'hq-pack-demo', '1.0.0')).toThrow(/boom/);
|
|
176
186
|
});
|
|
@@ -321,6 +331,7 @@ function makeDeps(overrides: Partial<PublishDeps> = {}): {
|
|
|
321
331
|
const deps: PublishDeps = {
|
|
322
332
|
loadIdToken: () => fakeIdToken({ sub: 'usr_1', email: 'ada@b.com', name: 'Ada' }),
|
|
323
333
|
getAccessToken: async () => 'access-token',
|
|
334
|
+
getIdToken: async () => 'id-token',
|
|
324
335
|
upload: uploadSpy,
|
|
325
336
|
hqRoot,
|
|
326
337
|
hqVersion: null,
|
|
@@ -541,3 +552,99 @@ describe('runPublish', () => {
|
|
|
541
552
|
expect(readManifest(hqRoot)).toBeNull();
|
|
542
553
|
});
|
|
543
554
|
});
|
|
555
|
+
|
|
556
|
+
describe('runPublish — publish on behalf (--as <handle>)', () => {
|
|
557
|
+
it('sends publishAsHandle + attributes the body to the TARGET handle (not the caller)', async () => {
|
|
558
|
+
const packDir = writeValidPack(tmpRoot);
|
|
559
|
+
const { deps, uploadSpy } = makeDeps();
|
|
560
|
+
|
|
561
|
+
const result = await runPublish(packDir, deps, { publishAsHandle: 'garrytan' });
|
|
562
|
+
|
|
563
|
+
const call = uploadSpy.mock.calls[0][0];
|
|
564
|
+
const listing = call.listing as ListingRequest;
|
|
565
|
+
// The on-behalf signal is sent to the server.
|
|
566
|
+
expect(listing.publishAsHandle).toBe('garrytan');
|
|
567
|
+
// Body attribution is the TARGET handle, never the caller's resolved handle.
|
|
568
|
+
expect(listing.creatorHandle).toBe('garrytan');
|
|
569
|
+
expect(listing.creatorHandle).not.toBe(result.author.handle);
|
|
570
|
+
// CRITICAL: on-behalf must authenticate with the ID token (staff gate reads
|
|
571
|
+
// the email claim, which the access token lacks → would 403 NO_EMAIL_CLAIM).
|
|
572
|
+
expect(call.token).toBe('id-token');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('on-behalf uses the ID token, normal publish uses the access token', async () => {
|
|
576
|
+
const packDir = writeValidPack(tmpRoot);
|
|
577
|
+
const onBehalf = makeDeps();
|
|
578
|
+
await runPublish(packDir, onBehalf.deps, { publishAsHandle: 'garrytan' });
|
|
579
|
+
expect(onBehalf.uploadSpy.mock.calls[0][0].token).toBe('id-token');
|
|
580
|
+
|
|
581
|
+
const normal = makeDeps();
|
|
582
|
+
await runPublish(packDir, normal.deps);
|
|
583
|
+
expect(normal.uploadSpy.mock.calls[0][0].token).toBe('access-token');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('preserves a pre-existing manifest author (does NOT stamp the caller) on the --as path', async () => {
|
|
587
|
+
// A ported pack already names the upstream creator. With --as we must NOT
|
|
588
|
+
// overwrite that author with the publishing staff caller — validation must
|
|
589
|
+
// pass against the manifest's own author block.
|
|
590
|
+
const packDir = writeValidPack(tmpRoot, {
|
|
591
|
+
...VALID_MANIFEST,
|
|
592
|
+
author: { uid: 'cmt_garrytan', handle: 'garrytan', displayName: 'Garry Tan' },
|
|
593
|
+
});
|
|
594
|
+
const { deps, uploadSpy } = makeDeps();
|
|
595
|
+
|
|
596
|
+
const result = await runPublish(packDir, deps, { publishAsHandle: 'garrytan' });
|
|
597
|
+
|
|
598
|
+
expect(uploadSpy).toHaveBeenCalledOnce();
|
|
599
|
+
expect(result.notice).toContain('pending_review');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('normal publish (no --as) sends NO publishAsHandle and keeps caller attribution', async () => {
|
|
603
|
+
const packDir = writeValidPack(tmpRoot);
|
|
604
|
+
const { deps, uploadSpy } = makeDeps();
|
|
605
|
+
|
|
606
|
+
const result = await runPublish(packDir, deps);
|
|
607
|
+
|
|
608
|
+
const listing = uploadSpy.mock.calls[0][0].listing as ListingRequest;
|
|
609
|
+
expect(listing.publishAsHandle).toBeUndefined();
|
|
610
|
+
expect(listing.creatorHandle).toBe(result.author.handle);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('an empty --as handle is treated as a normal self-publish', async () => {
|
|
614
|
+
const packDir = writeValidPack(tmpRoot);
|
|
615
|
+
const { deps, uploadSpy } = makeDeps();
|
|
616
|
+
|
|
617
|
+
await runPublish(packDir, deps, { publishAsHandle: '' });
|
|
618
|
+
|
|
619
|
+
const listing = uploadSpy.mock.calls[0][0].listing as ListingRequest;
|
|
620
|
+
expect(listing.publishAsHandle).toBeUndefined();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('server kill-switch off (400 ON_BEHALF_DISABLED) surfaces a clear error', async () => {
|
|
624
|
+
const packDir = writeValidPack(tmpRoot);
|
|
625
|
+
const { deps } = makeDeps({
|
|
626
|
+
upload: vi.fn(async () => ({
|
|
627
|
+
status: 400,
|
|
628
|
+
body: { code: 'ON_BEHALF_DISABLED' } as Record<string, unknown>,
|
|
629
|
+
})),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await expect(
|
|
633
|
+
runPublish(packDir, deps, { publishAsHandle: 'garrytan' }),
|
|
634
|
+
).rejects.toThrow(/on behalf|not enabled/i);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('non-staff caller (403 FORBIDDEN) surfaces a "staff only" error', async () => {
|
|
638
|
+
const packDir = writeValidPack(tmpRoot);
|
|
639
|
+
const { deps } = makeDeps({
|
|
640
|
+
upload: vi.fn(async () => ({
|
|
641
|
+
status: 403,
|
|
642
|
+
body: { code: 'FORBIDDEN' } as Record<string, unknown>,
|
|
643
|
+
})),
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await expect(
|
|
647
|
+
runPublish(packDir, deps, { publishAsHandle: 'garrytan' }),
|
|
648
|
+
).rejects.toThrow(/HQ staff|on behalf/i);
|
|
649
|
+
});
|
|
650
|
+
});
|
package/src/commands/publish.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { execFileSync } from 'child_process';
|
|
|
36
36
|
import { Command } from 'commander';
|
|
37
37
|
import chalk from 'chalk';
|
|
38
38
|
import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
39
|
-
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
39
|
+
import { ensureCognitoToken, ensureCognitoIdToken } from '../utils/cognito-session.js';
|
|
40
40
|
import { vaultApiFetch } from '../utils/vault-api.js';
|
|
41
41
|
import { validateManifest } from './pack-install.js';
|
|
42
42
|
import { findHqRoot, readManifest, writeManifest } from '../utils/manifest.js';
|
|
@@ -315,6 +315,16 @@ export function buildListingNotice(
|
|
|
315
315
|
`Bump the version in package.yaml and re-run \`hq publish\`.`,
|
|
316
316
|
);
|
|
317
317
|
}
|
|
318
|
+
if (status === 400) {
|
|
319
|
+
const code = typeof body.code === 'string' ? body.code : '';
|
|
320
|
+
if (code === 'ON_BEHALF_DISABLED') {
|
|
321
|
+
throw new Error(
|
|
322
|
+
'Publishing on behalf of another creator is not enabled on this server. ' +
|
|
323
|
+
'Ask an HQ admin to turn on the on-behalf capability, or publish without `--as`.',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
// Any other 400 falls through to the generic non-2xx handler below.
|
|
327
|
+
}
|
|
318
328
|
if (status === 401 || status === 403) {
|
|
319
329
|
const code = typeof body.code === 'string' ? body.code : '';
|
|
320
330
|
if (code === 'NOT_VERIFIED_CREATOR') {
|
|
@@ -324,6 +334,15 @@ export function buildListingNotice(
|
|
|
324
334
|
'an Indigo admin will review it.',
|
|
325
335
|
);
|
|
326
336
|
}
|
|
337
|
+
if (code === 'FORBIDDEN' || code === 'NO_EMAIL_CLAIM') {
|
|
338
|
+
// The staff gate rejected an on-behalf (`--as`) publish — only the normal
|
|
339
|
+
// self-publish path returns NOT_VERIFIED_CREATOR, so these codes here mean
|
|
340
|
+
// a non-staff caller tried to publish as someone else.
|
|
341
|
+
throw new Error(
|
|
342
|
+
'Only HQ staff can publish on behalf of another creator (`--as`). ' +
|
|
343
|
+
'Re-run without `--as` to publish as yourself.',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
327
346
|
throw new Error('Not authorized to publish — run `hq login` and ensure your creator account is verified.');
|
|
328
347
|
}
|
|
329
348
|
if (status < 200 || status >= 300) {
|
|
@@ -356,6 +375,13 @@ export interface ListingRequest {
|
|
|
356
375
|
summary?: string;
|
|
357
376
|
contributes?: string;
|
|
358
377
|
creatorHandle?: string;
|
|
378
|
+
/**
|
|
379
|
+
* OPTIONAL staff-gated publish-on-behalf target handle (`hq publish --as
|
|
380
|
+
* <handle>`). When set, the server attributes the listing to the seeded,
|
|
381
|
+
* UNCLAIMED creator with this handle instead of the caller — but only if the
|
|
382
|
+
* caller is staff (`@getindigo.ai`) and the server has the capability enabled.
|
|
383
|
+
*/
|
|
384
|
+
publishAsHandle?: string;
|
|
359
385
|
/** Base64-encoded gzip pack tarball (pack.tar.gz bytes). */
|
|
360
386
|
tarball: string;
|
|
361
387
|
}
|
|
@@ -363,8 +389,14 @@ export interface ListingRequest {
|
|
|
363
389
|
export interface PublishDeps {
|
|
364
390
|
/** Returns the cached idToken, or null/expired if not logged in. */
|
|
365
391
|
loadIdToken: () => string | null;
|
|
366
|
-
/** Returns a non-interactive access token. */
|
|
392
|
+
/** Returns a non-interactive access token (normal self-publish). */
|
|
367
393
|
getAccessToken: () => Promise<string>;
|
|
394
|
+
/**
|
|
395
|
+
* Returns a non-interactive ID token. Required for an on-behalf publish
|
|
396
|
+
* (`--as <handle>`): the server's staff gate reads the `email` claim, which is
|
|
397
|
+
* on the id_token only — the access token returns 403 NO_EMAIL_CLAIM.
|
|
398
|
+
*/
|
|
399
|
+
getIdToken: () => Promise<string>;
|
|
368
400
|
/**
|
|
369
401
|
* POSTs the listing as a JSON body to `/v1/listings` and returns the parsed
|
|
370
402
|
* { status, body }. `tarball` is the BASE64-encoded gzip pack bytes — the
|
|
@@ -396,6 +428,7 @@ export interface PublishResult {
|
|
|
396
428
|
export async function runPublish(
|
|
397
429
|
targetPath: string,
|
|
398
430
|
deps: PublishDeps,
|
|
431
|
+
opts: { publishAsHandle?: string } = {},
|
|
399
432
|
): Promise<PublishResult> {
|
|
400
433
|
// 1. Logged-in gate — refuse BEFORE any packing/upload.
|
|
401
434
|
const idToken = deps.loadIdToken();
|
|
@@ -403,6 +436,10 @@ export async function runPublish(
|
|
|
403
436
|
throw new Error('Not logged in — run `hq login` before publishing. Nothing was uploaded.');
|
|
404
437
|
}
|
|
405
438
|
const author = resolveAuthor(peekIdToken(idToken));
|
|
439
|
+
const publishAsHandle =
|
|
440
|
+
typeof opts.publishAsHandle === 'string' && opts.publishAsHandle.length > 0
|
|
441
|
+
? opts.publishAsHandle
|
|
442
|
+
: undefined;
|
|
406
443
|
|
|
407
444
|
// Resolve a payload dir. We stamp/validate against a temp copy so we never
|
|
408
445
|
// mutate the user's working tree's package.yaml.
|
|
@@ -420,7 +457,14 @@ export async function runPublish(
|
|
|
420
457
|
if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
|
|
421
458
|
throw new Error(`package.yaml missing from ${absTarget} — nothing was uploaded.`);
|
|
422
459
|
}
|
|
423
|
-
|
|
460
|
+
// On the on-behalf path (`--as <handle>`) the SERVER is authoritative for
|
|
461
|
+
// attribution (it resolves the target creator by handle and ignores the
|
|
462
|
+
// manifest/stamped author), so we must NOT overwrite the manifest's author
|
|
463
|
+
// with the CALLER's identity — the manifest already names the target
|
|
464
|
+
// creator. Stamp the caller only on the normal self-publish path.
|
|
465
|
+
if (!publishAsHandle) {
|
|
466
|
+
stampAuthorIntoPackage(payloadDir, author);
|
|
467
|
+
}
|
|
424
468
|
|
|
425
469
|
// 3. Validate with the canonical 10-rule validator. Throws on any failure;
|
|
426
470
|
// we have uploaded nothing at this point.
|
|
@@ -430,7 +474,13 @@ export async function runPublish(
|
|
|
430
474
|
// JSON.parses the body, so we send the gzip tarball base64-encoded
|
|
431
475
|
// inside a JSON listing request (NOT a raw octet-stream body).
|
|
432
476
|
const tarballBytes = tarballPayload(payloadDir);
|
|
433
|
-
|
|
477
|
+
// On-behalf publishes are staff-gated server-side and the staff gate reads
|
|
478
|
+
// the id_token `email` claim — send the ID token, not the access token
|
|
479
|
+
// (which has no email and would 403 NO_EMAIL_CLAIM). Normal self-publish
|
|
480
|
+
// continues to use the access token.
|
|
481
|
+
const token = publishAsHandle
|
|
482
|
+
? await deps.getIdToken()
|
|
483
|
+
: await deps.getAccessToken();
|
|
434
484
|
const listing: ListingRequest = {
|
|
435
485
|
type: inferListingType(pkg),
|
|
436
486
|
name: pkg.name,
|
|
@@ -438,9 +488,12 @@ export async function runPublish(
|
|
|
438
488
|
version: pkg.version,
|
|
439
489
|
summary: pkg.description,
|
|
440
490
|
contributes: summarizeContributes(pkg.contributes) || undefined,
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
491
|
+
// On-behalf: send the target handle so the body is self-describing (the
|
|
492
|
+
// server is authoritative for attribution either way). Normal: our
|
|
493
|
+
// resolved handle is a backwards-compat fallback under the server's
|
|
494
|
+
// claimed-handle lookup.
|
|
495
|
+
creatorHandle: publishAsHandle ?? author.handle,
|
|
496
|
+
...(publishAsHandle ? { publishAsHandle } : {}),
|
|
444
497
|
tarball: Buffer.from(tarballBytes).toString('base64'),
|
|
445
498
|
};
|
|
446
499
|
const { status, body } = await deps.upload({ token, listing });
|
|
@@ -468,8 +521,15 @@ export function registerPublishCommand(program: Command): void {
|
|
|
468
521
|
.description(
|
|
469
522
|
'Package a skill/worker pack and submit it to the HQ marketplace (POST /v1/listings).',
|
|
470
523
|
)
|
|
471
|
-
.
|
|
524
|
+
.option(
|
|
525
|
+
'--as <handle>',
|
|
526
|
+
'Publish on behalf of a seeded, unclaimed creator by handle (HQ staff only). ' +
|
|
527
|
+
'The listing is attributed to that creator instead of you.',
|
|
528
|
+
)
|
|
529
|
+
.option('--on-behalf <handle>', 'Alias for --as.')
|
|
530
|
+
.action(async (targetPath: string, options: { as?: string; onBehalf?: string }) => {
|
|
472
531
|
try {
|
|
532
|
+
const publishAsHandle = options.as ?? options.onBehalf;
|
|
473
533
|
const hqRoot = findHqRoot();
|
|
474
534
|
const result = await runPublish(targetPath, {
|
|
475
535
|
loadIdToken: () => {
|
|
@@ -478,6 +538,7 @@ export function registerPublishCommand(program: Command): void {
|
|
|
478
538
|
return cached.idToken;
|
|
479
539
|
},
|
|
480
540
|
getAccessToken: () => ensureCognitoToken({ interactive: false }),
|
|
541
|
+
getIdToken: () => ensureCognitoIdToken({ interactive: false }),
|
|
481
542
|
upload: async ({ token, listing }) => {
|
|
482
543
|
// JSON body (application/json) — the deployed handler JSON.parses
|
|
483
544
|
// the body and base64-decodes `listing.tarball`. vaultApiFetch
|
|
@@ -493,7 +554,7 @@ export function registerPublishCommand(program: Command): void {
|
|
|
493
554
|
},
|
|
494
555
|
hqRoot,
|
|
495
556
|
hqVersion: readHqVersion(hqRoot),
|
|
496
|
-
});
|
|
557
|
+
}, { publishAsHandle });
|
|
497
558
|
console.log(chalk.green(result.notice));
|
|
498
559
|
// Only print the locally-resolved author as a fallback when the server
|
|
499
560
|
// did NOT return its own resolved handle (already in the notice).
|
|
@@ -318,6 +318,62 @@ export async function ensureCognitoToken(options: {
|
|
|
318
318
|
return tokens.accessToken;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Return a non-expired Cognito **ID** token (mirrors `ensureCognitoToken`'s
|
|
323
|
+
* refresh/login flow but yields the id_token, not the access token).
|
|
324
|
+
*
|
|
325
|
+
* Staff-gated vault-API routes (`assertStaffCaller`) read the `email` claim,
|
|
326
|
+
* which Cognito puts on the ID token ONLY — the access token has no `email`, so
|
|
327
|
+
* sending it returns `403 NO_EMAIL_CLAIM`. Callers performing a staff action
|
|
328
|
+
* (e.g. `hq publish --as <handle>`) MUST send the ID token. For machine
|
|
329
|
+
* identities the vault API already authenticates with the ID token, so this is
|
|
330
|
+
* identical to `ensureCognitoToken` there.
|
|
331
|
+
*/
|
|
332
|
+
export async function ensureCognitoIdToken(options: {
|
|
333
|
+
interactive?: boolean;
|
|
334
|
+
} = {}): Promise<string> {
|
|
335
|
+
const interactive = options.interactive ?? true;
|
|
336
|
+
|
|
337
|
+
if (isMachineIdentity()) {
|
|
338
|
+
const machine = await getValidMachineTokens(DEFAULT_COGNITO);
|
|
339
|
+
return machine.idToken;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const cached = loadCachedTokens();
|
|
343
|
+
|
|
344
|
+
if (cached && !isExpiring(cached, 120)) {
|
|
345
|
+
return cached.idToken;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (cached) {
|
|
349
|
+
try {
|
|
350
|
+
if (interactive) {
|
|
351
|
+
console.error(chalk.dim(" Refreshing expiring HQ session..."));
|
|
352
|
+
}
|
|
353
|
+
const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
|
|
354
|
+
return refreshed.idToken;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (interactive) {
|
|
357
|
+
console.error(
|
|
358
|
+
chalk.dim(
|
|
359
|
+
` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!interactive) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
"No valid HQ session and interactive login is disabled. Run `hq login` first.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
|
|
373
|
+
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
374
|
+
return tokens.idToken;
|
|
375
|
+
}
|
|
376
|
+
|
|
321
377
|
/**
|
|
322
378
|
* The CLI's own ClientInfo. Resolved once at module load so every VaultClient
|
|
323
379
|
* built in this process stamps the same name/version onto its requests.
|