@@ -184,27 +184,42 @@ export async function upsertOauthSecret(
184184}
185185
186186/**
187- * Check whether the JiraWebhookSecret already holds a real signing secret
188- * (vs CDK's autogenerated placeholder). Used to decide whether to prompt
189- * for the webhook secret on subsequent setup runs .
187+ * Marker key embedded in the CDK-generated stack-wide webhook- secret
188+ * placeholder. A secret whose JSON carries this key has never been
189+ * configured by an operator, so ` setup` is free to seed the real value .
190190 *
191- * Atlassian's generic-webhook signing secrets are operator-chosen — they
192- * have no fixed prefix like Linear's `lin_wh_`. We treat the placeholder
193- * as a JSON-encoded value (CDK's default for autogenerated secrets) and
194- * everything else as a real value.
191+ * MUST stay in sync with `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY` in
192+ * `cdk/src/constructs/jira-integration.ts`. See #368.
195193 */
196- async function isWebhookSecretConfigured (
194+ export const JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY = 'abca_jira_webhook_placeholder' ;
195+
196+ /**
197+ * Check whether the JiraWebhookSecret already holds a real, operator-set
198+ * signing secret (vs the CDK-generated placeholder). Used to decide whether
199+ * to seed the stack-wide secret on a `setup` run.
200+ *
201+ * Atlassian's generic-webhook signing secrets are operator-chosen — they have
202+ * no fixed prefix like Linear's `lin_wh_`, so we cannot positively recognize a
203+ * *real* value by shape. Instead we recognize the *placeholder*: the CDK
204+ * construct seeds an explicit JSON object carrying
205+ * `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY`. Anything that is not that placeholder
206+ * is treated as an operator value.
207+ *
208+ * NOTE (#368 migration): stacks deployed before the explicit-placeholder fix
209+ * seeded a *bare random string* placeholder, which is indistinguishable from
210+ * an operator value and so is (conservatively) reported as configured. Such
211+ * installs must redeploy the CDK stack — which regenerates the secret with the
212+ * JSON placeholder — before `setup` will seed it.
213+ */
214+ export async function isWebhookSecretConfigured (
197215 client : SecretsManagerClient ,
198216 secretArn : string ,
199217) : Promise < boolean > {
200218 try {
201219 const result = await client . send ( new GetSecretValueCommand ( { SecretId : secretArn } ) ) ;
202220 const value = result . SecretString ;
203221 if ( typeof value !== 'string' || value . length === 0 ) return false ;
204- // CDK's auto-generated secret is a JSON object string starting with `{`
205- // — operator-set secrets (the Atlassian-side configured value) are bare
206- // strings. Anything that doesn't look like the placeholder JSON is real.
207- return ! value . trim ( ) . startsWith ( '{' ) ;
222+ return ! isWebhookSecretPlaceholder ( value ) ;
208223 } catch ( err ) {
209224 const errorName = ( err as { name ?: string } ) . name ;
210225 if ( errorName === 'ResourceNotFoundException' ) {
@@ -219,6 +234,29 @@ async function isWebhookSecretConfigured(
219234 }
220235}
221236
237+ /**
238+ * True when `value` is the CDK-generated placeholder — a JSON object carrying
239+ * the {@link JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY} marker. A non-JSON value, or
240+ * JSON without the marker, is an operator-set secret.
241+ */
242+ function isWebhookSecretPlaceholder ( value : string ) : boolean {
243+ const trimmed = value . trim ( ) ;
244+ // Fast reject: real Atlassian signing secrets are bare strings.
245+ if ( ! trimmed . startsWith ( '{' ) ) return false ;
246+ try {
247+ const parsed : unknown = JSON . parse ( trimmed ) ;
248+ return (
249+ typeof parsed === 'object'
250+ && parsed !== null
251+ && JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY in ( parsed as Record < string , unknown > )
252+ ) ;
253+ } catch {
254+ // Starts with `{` but isn't valid JSON — not our placeholder. Treat as a
255+ // (malformed) operator value rather than silently re-seeding over it.
256+ return false ; // nosemgrep: ts-silent-success-masking -- unparseable secret is conservatively treated as operator-set (not the placeholder), so setup never overwrites it
257+ }
258+ }
259+
222260function promptSecret ( label : string ) : Promise < string > {
223261 return new Promise ( ( resolve ) => {
224262 const rl = readline . createInterface ( { input : process . stdin , output : process . stdout } ) ;
0 commit comments