@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 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): Promise<PublishResult>;
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
@@ -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]="9b60d0b2-fea1-50bd-8c4e-e39163c38e30")}catch(e){}}();
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
- stampAuthorIntoPackage(payloadDir, author);
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
- const token = await deps.getAccessToken();
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
- // Server prefers the caller's claimed creator handle, but we stamp our
305
- // resolved handle as a backwards-compat fallback.
306
- creatorHandle: author.handle,
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
- .action(async (targetPath) => {
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=9b60d0b2-fea1-50bd-8c4e-e39163c38e30
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]="783fe478-9e12-55a7-a3d8-241ea2cf5886")}catch(e){}}();
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=783fe478-9e12-55a7-a3d8-241ea2cf5886
373
+ //# debugId=21e260f0-50fd-54c7-944a-c15630c42f3e
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.43.0",
3
+ "version": "5.44.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
+ });
@@ -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
- stampAuthorIntoPackage(payloadDir, author);
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
- const token = await deps.getAccessToken();
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
- // Server prefers the caller's claimed creator handle, but we stamp our
442
- // resolved handle as a backwards-compat fallback.
443
- creatorHandle: author.handle,
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
- .action(async (targetPath: string) => {
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.